@wireai/activation 0.1.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 (142) hide show
  1. package/AGENTS.md +73 -0
  2. package/CHANGELOG.md +572 -0
  3. package/INTEGRATION_PROMPT.md +55 -0
  4. package/LICENSE +21 -0
  5. package/README.md +923 -0
  6. package/dist/CenteredModal-C3qQBHsA.d.mts +38 -0
  7. package/dist/CenteredModal-Cdgns6--.d.ts +38 -0
  8. package/dist/coachmarks/index.d.mts +352 -0
  9. package/dist/coachmarks/index.d.ts +352 -0
  10. package/dist/coachmarks/index.js +944 -0
  11. package/dist/coachmarks/index.js.map +1 -0
  12. package/dist/coachmarks/index.mjs +918 -0
  13. package/dist/coachmarks/index.mjs.map +1 -0
  14. package/dist/decision-CFvGY6nP.d.ts +228 -0
  15. package/dist/decision-Cl8OFYzu.d.mts +228 -0
  16. package/dist/index.d.mts +1390 -0
  17. package/dist/index.d.ts +1390 -0
  18. package/dist/index.js +3260 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/index.mjs +3186 -0
  21. package/dist/index.mjs.map +1 -0
  22. package/dist/questionnaire/index.d.mts +249 -0
  23. package/dist/questionnaire/index.d.ts +249 -0
  24. package/dist/questionnaire/index.js +922 -0
  25. package/dist/questionnaire/index.js.map +1 -0
  26. package/dist/questionnaire/index.mjs +906 -0
  27. package/dist/questionnaire/index.mjs.map +1 -0
  28. package/dist/reviews/index.d.mts +213 -0
  29. package/dist/reviews/index.d.ts +213 -0
  30. package/dist/reviews/index.js +1001 -0
  31. package/dist/reviews/index.js.map +1 -0
  32. package/dist/reviews/index.mjs +971 -0
  33. package/dist/reviews/index.mjs.map +1 -0
  34. package/dist/showcase/index.d.mts +119 -0
  35. package/dist/showcase/index.d.ts +119 -0
  36. package/dist/showcase/index.js +660 -0
  37. package/dist/showcase/index.js.map +1 -0
  38. package/dist/showcase/index.mjs +651 -0
  39. package/dist/showcase/index.mjs.map +1 -0
  40. package/dist/types-BKfpdZzX.d.mts +84 -0
  41. package/dist/types-BKfpdZzX.d.ts +84 -0
  42. package/dist/types-CMuOexw0.d.mts +116 -0
  43. package/dist/types-CMuOexw0.d.ts +116 -0
  44. package/dist/types-GL_hQ0TN.d.mts +46 -0
  45. package/dist/types-GL_hQ0TN.d.ts +46 -0
  46. package/llms.txt +36 -0
  47. package/metro/index.d.ts +32 -0
  48. package/metro/index.js +83 -0
  49. package/package.json +171 -0
  50. package/src/DemoOnboarding.tsx +133 -0
  51. package/src/OnboardingFlow.tsx +504 -0
  52. package/src/WireOnboarding.tsx +248 -0
  53. package/src/analytics/analyticsEvent.ts +69 -0
  54. package/src/analytics/reportClientEvent.ts +119 -0
  55. package/src/analytics/sendPreview.ts +73 -0
  56. package/src/attribution/attribution.ts +46 -0
  57. package/src/cards/ChipSelectCard.tsx +265 -0
  58. package/src/cards/InterstitialCard.tsx +299 -0
  59. package/src/cards/NumberStepperCard.tsx +121 -0
  60. package/src/cards/SelectionCard.tsx +270 -0
  61. package/src/cards/StatusCard.tsx +170 -0
  62. package/src/cards/TextInputCard.tsx +147 -0
  63. package/src/cards/index.ts +23 -0
  64. package/src/coachmarks/CoachmarkOverlayHost.tsx +44 -0
  65. package/src/coachmarks/CoachmarkProvider.tsx +108 -0
  66. package/src/coachmarks/GestureHint.tsx +331 -0
  67. package/src/coachmarks/SpotlightOverlay.tsx +254 -0
  68. package/src/coachmarks/coachmarkAnchorRegistry.ts +70 -0
  69. package/src/coachmarks/coachmarkOverlayStore.ts +63 -0
  70. package/src/coachmarks/index.ts +62 -0
  71. package/src/coachmarks/runtime.ts +103 -0
  72. package/src/coachmarks/selectTourSteps.ts +45 -0
  73. package/src/coachmarks/types.ts +58 -0
  74. package/src/coachmarks/useCoachmarkAnchor.ts +49 -0
  75. package/src/coachmarks/useCoachmarkTour.ts +200 -0
  76. package/src/components/AnimatedSparkle.tsx +101 -0
  77. package/src/components/Button.tsx +129 -0
  78. package/src/components/CardHandoff.tsx +212 -0
  79. package/src/components/CardLayout.tsx +103 -0
  80. package/src/components/CenteredModal.tsx +184 -0
  81. package/src/components/CompletionView.tsx +186 -0
  82. package/src/components/DoneBlock.tsx +37 -0
  83. package/src/components/ErrorBlock.tsx +42 -0
  84. package/src/components/Illustration.tsx +36 -0
  85. package/src/components/LoadingBlock.tsx +182 -0
  86. package/src/components/LoadingScreen.tsx +35 -0
  87. package/src/components/OnboardingScaffold.tsx +105 -0
  88. package/src/components/StepProgress.tsx +85 -0
  89. package/src/components/loaderChrome.ts +28 -0
  90. package/src/config/onboardingFlag.ts +39 -0
  91. package/src/config/wireConfigFromEnv.ts +52 -0
  92. package/src/device/deviceContext.ts +158 -0
  93. package/src/features/WireFeaturesProvider.tsx +81 -0
  94. package/src/features/cache.ts +92 -0
  95. package/src/features/defaults.ts +67 -0
  96. package/src/features/fetchWireFeatures.ts +86 -0
  97. package/src/features/index.ts +39 -0
  98. package/src/features/types.ts +61 -0
  99. package/src/features/useWireFeatures.ts +57 -0
  100. package/src/identity/userIdentity.ts +100 -0
  101. package/src/illustrations/defaultIllustrations.tsx +99 -0
  102. package/src/index.ts +159 -0
  103. package/src/motion/centeredModalMotion.ts +45 -0
  104. package/src/motion/motionSpec.ts +133 -0
  105. package/src/motion/useReducedMotion.ts +43 -0
  106. package/src/questionnaire/QuestionnaireGate.tsx +235 -0
  107. package/src/questionnaire/decision.ts +63 -0
  108. package/src/questionnaire/index.ts +54 -0
  109. package/src/questionnaire/runtime.ts +20 -0
  110. package/src/questionnaire/transport.ts +83 -0
  111. package/src/questionnaire/types.ts +182 -0
  112. package/src/questionnaire/useQuestionnaireGate.ts +159 -0
  113. package/src/reviews/ReviewGate.tsx +316 -0
  114. package/src/reviews/ReviewModal.tsx +13 -0
  115. package/src/reviews/decision.ts +120 -0
  116. package/src/reviews/equality.ts +54 -0
  117. package/src/reviews/expo-store-review.d.ts +16 -0
  118. package/src/reviews/feedbackForm.ts +41 -0
  119. package/src/reviews/index.ts +78 -0
  120. package/src/reviews/presentation.ts +29 -0
  121. package/src/reviews/runtime.ts +45 -0
  122. package/src/reviews/storeReview.ts +62 -0
  123. package/src/reviews/transport.ts +89 -0
  124. package/src/reviews/types.ts +172 -0
  125. package/src/reviews/useReviewGate.ts +137 -0
  126. package/src/session/persistedSession.ts +143 -0
  127. package/src/session-analytics/index.ts +18 -0
  128. package/src/session-analytics/reportSessionStart.ts +144 -0
  129. package/src/session-analytics/useSessionStart.ts +114 -0
  130. package/src/showcase/FeatureShowcase.tsx +233 -0
  131. package/src/showcase/index.ts +19 -0
  132. package/src/showcase/selectShowcaseSlides.ts +45 -0
  133. package/src/showcase/showcaseColors.ts +91 -0
  134. package/src/showcase/types.ts +74 -0
  135. package/src/theme/ThemeContext.tsx +38 -0
  136. package/src/theme/defaultTheme.ts +70 -0
  137. package/src/theme/themeFromBrand.ts +93 -0
  138. package/src/theme/types.ts +88 -0
  139. package/src/theme/typography.ts +46 -0
  140. package/src/types.ts +215 -0
  141. package/src/utils/deriveAnswers.ts +43 -0
  142. package/src/utils/readProgress.ts +33 -0
@@ -0,0 +1,316 @@
1
+ /**
2
+ * ReviewGate — the in-app review sentiment gate UI.
3
+ *
4
+ * A NEUTRAL prompt ("Enjoying <app>?") over five tappable stars. The honest split:
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.
12
+ *
13
+ * The form NEVER asks for identity (no name / email / contact field): the host already
14
+ * identifies the user via `sessionId` + `meta` (e.g. user_id, device_key), which is all
15
+ * the POST body carries alongside the rating and the free text.
16
+ *
17
+ * Every visual value comes from the OnboardingTheme (useOnboardingTheme). Analytics are
18
+ * callback-based (mirroring the coachmark convention): the host wires `onEvent` to its own
19
+ * analytics. No PII in events — feedback text goes ONLY in the POST body.
20
+ *
21
+ * Store-policy: `requestReview` is quota'd and never guaranteed to show; it is called ONLY
22
+ * on the 5-star path, never from a "rate us 5 stars" button. See the README notes.
23
+ */
24
+ import React, { useCallback, useMemo, useState } from "react";
25
+ import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
26
+
27
+ import { Button } from "../components/Button";
28
+ import { useOnboardingTheme } from "../theme/ThemeContext";
29
+ import type { OnboardingTheme } from "../theme/types";
30
+ import { bodyStyle, captionStyle, headingStyle } from "../theme/typography";
31
+ import { buildReviewSubmission, routeRating } from "./decision";
32
+ import { feedbackInputHeights } from "./feedbackForm";
33
+ import { resolvePresentation } from "./presentation";
34
+ import { ReviewModal, type ReviewModalHandle } from "./ReviewModal";
35
+ import { requestStoreReview } from "./storeReview";
36
+ 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
+
53
+ type Phase = "rating" | "feedback" | "thanks";
54
+
55
+ export const ReviewGate: React.FC<ReviewGateProps> = ({
56
+ appName,
57
+ store,
58
+ target,
59
+ sessionId,
60
+ meta,
61
+ onEvent,
62
+ onResolved,
63
+ onShown,
64
+ theme: themeOverride,
65
+ dismissible = true,
66
+ presentation,
67
+ id = "default",
68
+ }) => {
69
+ const active = useOnboardingTheme();
70
+ const t = useMemo(() => mergeThemeOver(active, themeOverride), [active, themeOverride]);
71
+ const mode = resolvePresentation(presentation);
72
+ const modalRef = React.useRef<ReviewModalHandle>(null);
73
+
74
+ const [phase, setPhase] = useState<Phase>("rating");
75
+ const [stars, setStars] = useState(0);
76
+ const [hovered, setHovered] = useState(0);
77
+ const [feedback, setFeedback] = useState("");
78
+ const [suggestion, setSuggestion] = useState("");
79
+
80
+ // Fire review_prompt_shown exactly ONCE per mount. The callbacks are read through
81
+ // refs (not effect deps) so an inline `onShown`/`onEvent` — recreated every render,
82
+ // as real hosts idiomatically pass — never re-runs the effect. Empty deps + the
83
+ // shownRef guard make this a true fire-once, immune to prop-identity churn.
84
+ const shownRef = React.useRef(false);
85
+ const onShownRef = React.useRef(onShown);
86
+ const onEventRef = React.useRef(onEvent);
87
+ onShownRef.current = onShown;
88
+ onEventRef.current = onEvent;
89
+ React.useEffect(() => {
90
+ if (shownRef.current) return;
91
+ shownRef.current = true;
92
+ onShownRef.current?.();
93
+ onEventRef.current?.({ name: "review_prompt_shown", id });
94
+ // eslint-disable-next-line react-hooks/exhaustive-deps
95
+ }, []);
96
+
97
+ const resolve = useCallback(() => {
98
+ onResolved?.();
99
+ }, [onResolved]);
100
+
101
+ const pickRating = useCallback(
102
+ async (value: number) => {
103
+ setStars(value);
104
+ onEvent?.({ name: "review_rating_selected", id, stars: value });
105
+ if (routeRating(value) === "store") {
106
+ onEvent?.({ name: "store_review_requested", id, stars: value });
107
+ await requestStoreReview(store);
108
+ setPhase("thanks");
109
+ resolve();
110
+ } else {
111
+ setPhase("feedback");
112
+ }
113
+ },
114
+ [onEvent, id, store, resolve],
115
+ );
116
+
117
+ const sendFeedback = useCallback(() => {
118
+ // Identity is NEVER collected here — the host already sends user_id / device /
119
+ // session via `sessionId` + `meta`. We POST only the rating + the free text.
120
+ const body = buildReviewSubmission({
121
+ stars,
122
+ feedbackText: feedback,
123
+ suggestion,
124
+ sessionId,
125
+ meta,
126
+ });
127
+ submitReview(target, body);
128
+ onEvent?.({ name: "review_feedback_submitted", id, stars });
129
+ setPhase("thanks");
130
+ resolve();
131
+ }, [stars, feedback, suggestion, sessionId, meta, target, onEvent, id, resolve]);
132
+
133
+ const dismiss = useCallback(() => {
134
+ setPhase("thanks");
135
+ resolve();
136
+ }, [resolve]);
137
+
138
+ // The in-card "Not now"/"Skip" link. In modal mode it plays the popup's exit animation
139
+ // (which resolves on completion, the same path a backdrop tap takes); inline keeps the
140
+ // legacy behavior of swapping to the thank-you in place.
141
+ const handleDismissTap = useCallback(() => {
142
+ if (mode === "modal") modalRef.current?.close();
143
+ else dismiss();
144
+ }, [mode, dismiss]);
145
+
146
+ const title =
147
+ phase === "feedback"
148
+ ? "What should we improve?"
149
+ : phase === "thanks"
150
+ ? "Thank you"
151
+ : appName
152
+ ? `Enjoying ${appName}?`
153
+ : "How's your experience?";
154
+
155
+ const surface = {
156
+ backgroundColor: t.colors.surface,
157
+ borderRadius: t.radius.lg,
158
+ padding: t.spacing.lg,
159
+ gap: t.spacing.md,
160
+ } as const;
161
+
162
+ // A multiline box ~4 lines tall (capped at ~5), derived from the theme's body
163
+ // line-height so it scales with the app's font config. The field's own vertical
164
+ // padding is folded in so the requested lines are visible inside the padding.
165
+ const feedbackBox = feedbackInputHeights(t.fonts, t.spacing.md);
166
+
167
+ // The phase content, shared by both presentations. In "modal" mode ReviewModal supplies
168
+ // the centered, dimmed, animated card surface; in "inline" mode the bare card below does.
169
+ const content = (
170
+ <>
171
+ <Text style={[headingStyle(t.fonts), { color: t.colors.text }]}>{title}</Text>
172
+
173
+ {phase === "rating" && (
174
+ <>
175
+ <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
176
+ Tap a star to let us know.
177
+ </Text>
178
+ <View style={styles.stars}>
179
+ {[1, 2, 3, 4, 5].map((n) => {
180
+ const filled = n <= (hovered || stars);
181
+ return (
182
+ <Pressable
183
+ key={n}
184
+ onPressIn={() => setHovered(n)}
185
+ onPressOut={() => setHovered(0)}
186
+ onPress={() => void pickRating(n)}
187
+ accessibilityRole="button"
188
+ accessibilityLabel={`${n} star${n > 1 ? "s" : ""}`}
189
+ hitSlop={8}
190
+ >
191
+ <Text
192
+ style={[
193
+ styles.star,
194
+ { color: filled ? t.colors.primary : t.colors.border },
195
+ ]}
196
+ >
197
+ {filled ? "★" : "☆"}
198
+ </Text>
199
+ </Pressable>
200
+ );
201
+ })}
202
+ </View>
203
+ {dismissible && (
204
+ <Pressable onPress={handleDismissTap} hitSlop={8}>
205
+ <Text style={[captionStyle(t.fonts), styles.link, { color: t.colors.textMuted }]}>
206
+ Not now
207
+ </Text>
208
+ </Pressable>
209
+ )}
210
+ </>
211
+ )}
212
+
213
+ {phase === "feedback" && (
214
+ <>
215
+ <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
216
+ A few words is plenty. Both boxes are optional, so send whatever you like.
217
+ </Text>
218
+ {/* Two OPTIONAL inputs. No name / email / contact field: the host already
219
+ identifies the user via sessionId + meta, so we never ask for identity
220
+ here. Send stays enabled whether the user fills one box, both, or neither
221
+ (the rating is the only mandatory action). */}
222
+ <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
223
+ Why this rating? (optional)
224
+ </Text>
225
+ <TextInput
226
+ value={feedback}
227
+ onChangeText={setFeedback}
228
+ placeholder="e.g. too many steps"
229
+ placeholderTextColor={t.colors.textMuted}
230
+ multiline
231
+ textAlignVertical="top"
232
+ style={[
233
+ bodyStyle(t.fonts),
234
+ styles.input,
235
+ {
236
+ color: t.colors.text,
237
+ backgroundColor: t.colors.background,
238
+ borderRadius: t.radius.md,
239
+ borderColor: t.colors.border,
240
+ padding: t.spacing.md,
241
+ minHeight: feedbackBox.minHeight,
242
+ maxHeight: feedbackBox.maxHeight,
243
+ },
244
+ ]}
245
+ />
246
+ <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
247
+ What can we do better? (optional)
248
+ </Text>
249
+ <TextInput
250
+ value={suggestion}
251
+ onChangeText={setSuggestion}
252
+ placeholder="e.g. save my place between steps"
253
+ placeholderTextColor={t.colors.textMuted}
254
+ style={[
255
+ bodyStyle(t.fonts),
256
+ styles.input,
257
+ {
258
+ color: t.colors.text,
259
+ backgroundColor: t.colors.background,
260
+ borderRadius: t.radius.md,
261
+ borderColor: t.colors.border,
262
+ paddingHorizontal: t.spacing.md,
263
+ paddingVertical: t.spacing.sm,
264
+ minHeight: t.button.height,
265
+ },
266
+ ]}
267
+ />
268
+ <Button
269
+ title="Send feedback"
270
+ onPress={sendFeedback}
271
+ variant="primary"
272
+ fullWidth
273
+ />
274
+ {dismissible && (
275
+ <Pressable onPress={handleDismissTap} hitSlop={8}>
276
+ <Text style={[captionStyle(t.fonts), styles.link, { color: t.colors.textMuted }]}>
277
+ Skip
278
+ </Text>
279
+ </Pressable>
280
+ )}
281
+ </>
282
+ )}
283
+
284
+ {phase === "thanks" && (
285
+ <Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted }]}>
286
+ We appreciate you. This helps us make it better.
287
+ </Text>
288
+ )}
289
+ </>
290
+ );
291
+
292
+ // Default: a centered popup over a dimmed backdrop with a spring/fade entrance. A backdrop
293
+ // tap (or Android back) resolves via the same path onResolved already tracks.
294
+ if (mode === "modal") {
295
+ return (
296
+ <ReviewModal ref={modalRef} theme={t} dismissible={dismissible} onDismiss={resolve}>
297
+ {content}
298
+ </ReviewModal>
299
+ );
300
+ }
301
+
302
+ // Legacy: the bare card, positioned by wherever the host mounts it.
303
+ return <View style={[styles.card, surface]}>{content}</View>;
304
+ };
305
+
306
+ ReviewGate.displayName = "ReviewGate";
307
+
308
+ const styles = StyleSheet.create({
309
+ card: { width: "100%" },
310
+ stars: { flexDirection: "row", justifyContent: "center", gap: 8 },
311
+ star: { fontSize: 40, lineHeight: 46 },
312
+ input: { borderWidth: 1 },
313
+ link: { textAlign: "center", textDecorationLine: "underline" },
314
+ });
315
+
316
+ export default ReviewGate;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * ReviewModal - back-compat re-export of the shared CenteredModal shell.
3
+ *
4
+ * The centered-popup presentation was extracted, unchanged, to `components/CenteredModal` so the
5
+ * questionnaire module can reuse the same shell without importing reviews/. This file keeps the
6
+ * reviews public API (`ReviewModal`, `ReviewModalHandle`, `ReviewModalProps`) pointing at the
7
+ * exact same runtime object, so the reviews canary and every existing import stay valid.
8
+ */
9
+ export { CenteredModal as ReviewModal, default } from "../components/CenteredModal";
10
+ export type {
11
+ CenteredModalHandle as ReviewModalHandle,
12
+ CenteredModalProps as ReviewModalProps,
13
+ } from "../components/CenteredModal";
@@ -0,0 +1,120 @@
1
+ /**
2
+ * decision.ts — the pure decision logic for the review gate.
3
+ *
4
+ * `decideReview` is the AI seam, mirroring `selectTourSteps` exactly: ONE function that
5
+ * turns an optional injected decision into the live verdict. No injected decision → the
6
+ * local rules stand; a server decision present → it OVERRIDES them. Building the seam now
7
+ * is the whole cost of being AI-ready: today the host passes local rules only; later the
8
+ * Wire server computes `{fire, reason}` from the onboarding learnings and the host passes
9
+ * it straight in — zero app changes.
10
+ *
11
+ * // v1 (local rules only)
12
+ * const verdict = decideReview(evaluateGate(rules, signals));
13
+ *
14
+ * // AI on (server-provided decision wins)
15
+ * const verdict = decideReview(evaluateGate(rules, signals), serverDecision);
16
+ *
17
+ * `evaluateGate`, `routeRating`, and `buildReviewSubmission` are pure so the gate logic
18
+ * is verifiable in isolation (the kit has no test runner; correctness lives in these).
19
+ */
20
+ import type {
21
+ RatingRoute,
22
+ ReviewDecision,
23
+ ReviewSubmission,
24
+ } from "./types";
25
+
26
+ /**
27
+ * The seam. `decision` absent → the local verdict stands; present → it overrides.
28
+ * Same contract philosophy as `selectTourSteps(catalog, selection?)`.
29
+ */
30
+ export const decideReview = (
31
+ local: ReviewDecision,
32
+ decision?: ReviewDecision,
33
+ ): ReviewDecision => decision ?? local;
34
+
35
+ /** The signals the local rules evaluate against (read from injected sync storage + host). */
36
+ export interface GateSignals {
37
+ /** Sessions observed on this device (incl. the current one). */
38
+ sessions: number;
39
+ /** Tracked app events observed (host-owned). */
40
+ events: number;
41
+ /** Epoch ms of the last time the gate was shown, or null if never. */
42
+ lastShownAt: number | null;
43
+ /** Whether the once-per-version gate is already satisfied. */
44
+ seen: boolean;
45
+ /** Current epoch ms (injected so the function stays pure). */
46
+ now: number;
47
+ }
48
+
49
+ /** The resolved local rules (defaults applied). */
50
+ export interface GateRules {
51
+ enabled: boolean;
52
+ minSessions: number;
53
+ minEvents: number;
54
+ cooldownDays: number;
55
+ oncePerVersion: boolean;
56
+ }
57
+
58
+ /** Fill a partial `ReviewConfig` with the rule defaults. */
59
+ export const resolveRules = (config: {
60
+ enabled?: boolean;
61
+ minSessions?: number;
62
+ minEvents?: number;
63
+ cooldownDays?: number;
64
+ oncePerVersion?: boolean;
65
+ }): GateRules => ({
66
+ enabled: config.enabled ?? true,
67
+ minSessions: config.minSessions ?? 0,
68
+ minEvents: config.minEvents ?? 0,
69
+ cooldownDays: config.cooldownDays ?? 0,
70
+ oncePerVersion: config.oncePerVersion ?? true,
71
+ });
72
+
73
+ const MS_PER_DAY = 86_400_000;
74
+
75
+ /**
76
+ * Deterministic v1 local decision. Order: disabled → once-gate → min-sessions →
77
+ * min-events → cooldown → fire. Reasons mirror the server evaluator's vocabulary.
78
+ */
79
+ export const evaluateGate = (rules: GateRules, s: GateSignals): ReviewDecision => {
80
+ if (!rules.enabled) return { fire: false, reason: "disabled" };
81
+ if (rules.oncePerVersion && s.seen) return { fire: false, reason: "already_shown" };
82
+ if (s.sessions < rules.minSessions) return { fire: false, reason: "min_sessions" };
83
+ if (s.events < rules.minEvents) return { fire: false, reason: "min_events" };
84
+ if (rules.cooldownDays > 0 && s.lastShownAt != null) {
85
+ const elapsedDays = (s.now - s.lastShownAt) / MS_PER_DAY;
86
+ if (elapsedDays < rules.cooldownDays) return { fire: false, reason: "cooldown" };
87
+ }
88
+ return { fire: true, reason: "local_rules_met" };
89
+ };
90
+
91
+ /**
92
+ * Route a rating. 5 stars → the native store review; 1-4 → the feedback form. The
93
+ * sentiment question itself is neutral — this is the ONLY place 5 is treated specially,
94
+ * never a button labeled "rate us 5 stars" (see the store-policy notes in the README).
95
+ */
96
+ export const routeRating = (stars: number): RatingRoute =>
97
+ stars >= 5 ? "store" : "feedback";
98
+
99
+ /** Build the `POST /v1/reviews` body. Feedback text/contact belong ONLY here, never in events. */
100
+ export const buildReviewSubmission = (input: {
101
+ stars: number;
102
+ feedbackText?: string;
103
+ suggestion?: string;
104
+ contact?: string;
105
+ sessionId?: string;
106
+ source?: string;
107
+ meta?: Record<string, unknown>;
108
+ }): ReviewSubmission => {
109
+ const body: ReviewSubmission = { stars: input.stars };
110
+ const text = input.feedbackText?.trim();
111
+ if (text) body.feedback_text = text;
112
+ const suggestion = input.suggestion?.trim();
113
+ if (suggestion) body.suggestion = suggestion;
114
+ const contact = input.contact?.trim();
115
+ if (contact) body.contact = contact;
116
+ if (input.sessionId) body.session_id = input.sessionId;
117
+ if (input.source) body.source = input.source;
118
+ if (input.meta && Object.keys(input.meta).length > 0) body.meta = input.meta;
119
+ return body;
120
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * equality.ts — tiny, dependency-free value-equality helpers used to STABILIZE
3
+ * references across renders.
4
+ *
5
+ * WHY this exists: real integrators pass inline objects — `config={{ id: 'x' }}`,
6
+ * `decision={{ fire: true }}` — whose IDENTITY changes every render even though
7
+ * their VALUES do not. If the review hook let those unstable references flow into
8
+ * a `useMemo`/`useEffect` dependency list (or handed a fresh verdict object back
9
+ * every render), a host that depends on the returned value would re-run its own
10
+ * effect forever → "Maximum update depth exceeded". A library must tolerate inline
11
+ * props without looping; these helpers are how the hook holds a stable reference
12
+ * whenever the underlying values are unchanged.
13
+ *
14
+ * Pure (no React import) so it runs under the kit's node:test runner.
15
+ */
16
+ import type { ReviewDecision } from "./types";
17
+
18
+ /**
19
+ * One-level value-equality of two plain objects (or primitives). Same-reference is
20
+ * equal; two objects are equal when they have the same keys and each value is
21
+ * `Object.is`-equal. Good enough for the flat `config` / `decision` shapes the gate
22
+ * accepts (all leaf values are primitives).
23
+ */
24
+ export const shallowEqual = (a: unknown, b: unknown): boolean => {
25
+ if (Object.is(a, b)) return true;
26
+ if (
27
+ a == null ||
28
+ b == null ||
29
+ typeof a !== "object" ||
30
+ typeof b !== "object"
31
+ ) {
32
+ return false;
33
+ }
34
+ const ak = Object.keys(a as Record<string, unknown>);
35
+ const bk = Object.keys(b as Record<string, unknown>);
36
+ if (ak.length !== bk.length) return false;
37
+ for (const k of ak) {
38
+ if (!Object.is((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) {
39
+ return false;
40
+ }
41
+ }
42
+ return true;
43
+ };
44
+
45
+ /**
46
+ * Structural equality of two review verdicts by their contract fields (`fire`,
47
+ * `reason`). A `null`/`undefined` verdict equals only another absent one. Used to
48
+ * decide whether the hook can keep returning the SAME verdict object (so a host
49
+ * that lists `controller.decision` in an effect dep array never loops).
50
+ */
51
+ export const sameDecision = (a?: ReviewDecision | null, b?: ReviewDecision | null): boolean => {
52
+ if (a == null || b == null) return a == b;
53
+ return a.fire === b.fire && a.reason === b.reason;
54
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Ambient type for the OPTIONAL peer `expo-store-review`.
3
+ *
4
+ * The reviews module never hard-depends on it: it is loaded lazily via a guarded
5
+ * dynamic import and externalized from the build, so the core kit installs nothing.
6
+ * This declaration lets the module typecheck whether or not the host has the package
7
+ * installed. When the host does install it, Metro/the bundler resolves the real module
8
+ * at runtime; when it doesn't, the guarded import rejects and the kit falls back to
9
+ * opening the configured store URLs.
10
+ */
11
+ declare module "expo-store-review" {
12
+ /** Whether the native in-app review dialog can be requested on this device. */
13
+ export function isAvailableAsync(): Promise<boolean>;
14
+ /** Ask the OS to show its native in-app review prompt (quota'd, never guaranteed). */
15
+ export function requestReview(): Promise<void>;
16
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * feedbackForm — pure sizing helpers for the 1–4★ feedback form, kept out of the
3
+ * React component so the "≈4 lines tall, capped at 5" rule is verifiable in
4
+ * isolation (the reviews module's correctness lives in these pure functions, the
5
+ * same convention as `decision.ts` / `presentation.ts`).
6
+ *
7
+ * Malik's ask: the feedback field should be a multiline box "around 3 to 5 lines"
8
+ * tall, not a single-line input. We size it from the theme's body line-height so
9
+ * it scales with the app's font config.
10
+ */
11
+ import type { OnboardingFonts } from "../theme/types";
12
+ import { bodyStyle } from "../theme/typography";
13
+
14
+ /** Lines the box shows at rest, and the cap it can grow to. */
15
+ export const FEEDBACK_MIN_LINES = 4;
16
+ export const FEEDBACK_MAX_LINES = 5;
17
+
18
+ export interface FeedbackInputHeights {
19
+ /** Resting height — the box opens at ~4 lines so it reads as "write a few words". */
20
+ minHeight: number;
21
+ /** Growth cap — it never balloons past ~5 lines; scroll takes over beyond that. */
22
+ maxHeight: number;
23
+ }
24
+
25
+ /**
26
+ * Derive the multiline feedback box's min/max height from the theme's body
27
+ * line-height. `minHeight = MIN_LINES × lineHeight`, capped at
28
+ * `MAX_LINES × lineHeight`, plus the field's vertical padding (top + bottom) so
29
+ * the requested number of text lines is actually visible inside the padding.
30
+ */
31
+ export const feedbackInputHeights = (
32
+ fonts: OnboardingFonts,
33
+ verticalPadding = 0,
34
+ ): FeedbackInputHeights => {
35
+ const lineHeight = bodyStyle(fonts).lineHeight ?? Math.round(fonts.baseSize * fonts.scale * 1.5);
36
+ const pad = verticalPadding * 2;
37
+ return {
38
+ minHeight: lineHeight * FEEDBACK_MIN_LINES + pad,
39
+ maxHeight: lineHeight * FEEDBACK_MAX_LINES + pad,
40
+ };
41
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * wireai-onboarding/reviews — the in-app review sentiment gate.
3
+ *
4
+ * Subpath entry, kept OUT of the main barrel so the core kit stays dependency-free:
5
+ * importing this pulls in the OPTIONAL peer `expo-store-review` (loaded lazily and only on
6
+ * the 5-star path). The gate asks a neutral question, routes 5 stars to the native store
7
+ * review and 1-4 to a feather-light feedback form POSTed to the Wire server, and decides
8
+ * WHEN to show via local rules OR a server-provided decision (the AI seam).
9
+ *
10
+ * import { ReviewGate, useReviewGate } from "wireai-onboarding/reviews";
11
+ */
12
+
13
+ // ─── The gate UI ──────────────────────────────────────────────────────────────
14
+ export { ReviewGate } from "./ReviewGate";
15
+
16
+ // ─── The centered-popup presentation (default) + its pure spec ─────────────────
17
+ export { ReviewModal } from "./ReviewModal";
18
+ export type { ReviewModalHandle, ReviewModalProps } from "./ReviewModal";
19
+ export {
20
+ resolvePresentation,
21
+ DEFAULT_PRESENTATION,
22
+ REVIEW_MODAL_ANIM,
23
+ entranceFrame,
24
+ } from "./presentation";
25
+
26
+ // ─── The WHEN decision (local rules + the server-decision override) ───────────
27
+ export { useReviewGate } from "./useReviewGate";
28
+
29
+ // ─── The AI seam + pure gate logic (verifiable in isolation) ──────────────────
30
+ export {
31
+ decideReview,
32
+ evaluateGate,
33
+ resolveRules,
34
+ routeRating,
35
+ buildReviewSubmission,
36
+ } from "./decision";
37
+ export type { GateSignals, GateRules } from "./decision";
38
+
39
+ // ─── Value-equality helpers (stabilize inline-identity props → no render loop) ─
40
+ export { shallowEqual, sameDecision } from "./equality";
41
+
42
+ // ─── Feedback-form sizing (the ~4-line multiline box) ─────────────────────────
43
+ export {
44
+ feedbackInputHeights,
45
+ FEEDBACK_MIN_LINES,
46
+ FEEDBACK_MAX_LINES,
47
+ } from "./feedbackForm";
48
+ export type { FeedbackInputHeights } from "./feedbackForm";
49
+
50
+ // ─── The 5-star store path (optional peer + URL fallback) ─────────────────────
51
+ export { requestStoreReview, openStoreListing, storeUrl } from "./storeReview";
52
+
53
+ // ─── Server transport: submit a review + report generic app.* events ──────────
54
+ export { submitReview, reportAppEvent } from "./transport";
55
+ export type { ReportAppEventOptions } from "./transport";
56
+
57
+ // ─── Gate-key + storage helpers (shared coachmark storage singleton) ──────────
58
+ export {
59
+ reviewSeenKey,
60
+ reviewLastShownKey,
61
+ reviewSessionsKey,
62
+ } from "./runtime";
63
+
64
+ // ─── Types ────────────────────────────────────────────────────────────────────
65
+ export type {
66
+ ReviewDecision,
67
+ RatingRoute,
68
+ ReviewConfig,
69
+ ReviewStoreConfig,
70
+ ReviewTarget,
71
+ ReviewSubmission,
72
+ ReviewGateProps,
73
+ ReviewPresentation,
74
+ ReviewGateEvent,
75
+ ReviewGateEventName,
76
+ UseReviewGateOptions,
77
+ ReviewGateController,
78
+ } from "./types";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * presentation.ts — the pure presentation + entrance-animation logic for the review gate.
3
+ *
4
+ * WHY split out: the gate UI (ReviewGate/ReviewModal) is RN and can't run under the kit's
5
+ * node:test runner, but the presentation DEFAULT and the entrance math are pure and worth
6
+ * pinning. Keep the animation spec here so both the component and the tests read one source.
7
+ *
8
+ * The centered popup uses RN's built-in `Animated` (native driver) — the same idiom as
9
+ * CompletionView/CardHandoff — so the reviews module stays dependency-free (no reanimated).
10
+ */
11
+ import type { ReviewPresentation } from "./types";
12
+
13
+ /** Centered popup by default (Malik's ruling: the bottom-anchored inline card read as wrong). */
14
+ export const DEFAULT_PRESENTATION: ReviewPresentation = "modal";
15
+
16
+ /** Resolve the presentation prop → the effective mode. Absent → the centered modal. */
17
+ export const resolvePresentation = (p?: ReviewPresentation): ReviewPresentation =>
18
+ p ?? DEFAULT_PRESENTATION;
19
+
20
+ /**
21
+ * The centered-popup entrance spec + pure entrance-frame math now live in the neutral
22
+ * `../motion/centeredModalMotion` (the shell was extracted to `components/CenteredModal` so the
23
+ * questionnaire module can reuse it without importing reviews/). Re-exported here under their
24
+ * original names so the reviews public API (reviews/index.ts) and presentation.test.ts stay valid.
25
+ */
26
+ export {
27
+ CENTERED_MODAL_ANIM as REVIEW_MODAL_ANIM,
28
+ centeredModalFrame as entranceFrame,
29
+ } from "../motion/centeredModalMotion";