@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.
- package/AGENTS.md +73 -0
- package/CHANGELOG.md +572 -0
- package/INTEGRATION_PROMPT.md +55 -0
- package/LICENSE +21 -0
- package/README.md +923 -0
- package/dist/CenteredModal-C3qQBHsA.d.mts +38 -0
- package/dist/CenteredModal-Cdgns6--.d.ts +38 -0
- package/dist/coachmarks/index.d.mts +352 -0
- package/dist/coachmarks/index.d.ts +352 -0
- package/dist/coachmarks/index.js +944 -0
- package/dist/coachmarks/index.js.map +1 -0
- package/dist/coachmarks/index.mjs +918 -0
- package/dist/coachmarks/index.mjs.map +1 -0
- package/dist/decision-CFvGY6nP.d.ts +228 -0
- package/dist/decision-Cl8OFYzu.d.mts +228 -0
- package/dist/index.d.mts +1390 -0
- package/dist/index.d.ts +1390 -0
- package/dist/index.js +3260 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3186 -0
- package/dist/index.mjs.map +1 -0
- package/dist/questionnaire/index.d.mts +249 -0
- package/dist/questionnaire/index.d.ts +249 -0
- package/dist/questionnaire/index.js +922 -0
- package/dist/questionnaire/index.js.map +1 -0
- package/dist/questionnaire/index.mjs +906 -0
- package/dist/questionnaire/index.mjs.map +1 -0
- package/dist/reviews/index.d.mts +213 -0
- package/dist/reviews/index.d.ts +213 -0
- package/dist/reviews/index.js +1001 -0
- package/dist/reviews/index.js.map +1 -0
- package/dist/reviews/index.mjs +971 -0
- package/dist/reviews/index.mjs.map +1 -0
- package/dist/showcase/index.d.mts +119 -0
- package/dist/showcase/index.d.ts +119 -0
- package/dist/showcase/index.js +660 -0
- package/dist/showcase/index.js.map +1 -0
- package/dist/showcase/index.mjs +651 -0
- package/dist/showcase/index.mjs.map +1 -0
- package/dist/types-BKfpdZzX.d.mts +84 -0
- package/dist/types-BKfpdZzX.d.ts +84 -0
- package/dist/types-CMuOexw0.d.mts +116 -0
- package/dist/types-CMuOexw0.d.ts +116 -0
- package/dist/types-GL_hQ0TN.d.mts +46 -0
- package/dist/types-GL_hQ0TN.d.ts +46 -0
- package/llms.txt +36 -0
- package/metro/index.d.ts +32 -0
- package/metro/index.js +83 -0
- package/package.json +171 -0
- package/src/DemoOnboarding.tsx +133 -0
- package/src/OnboardingFlow.tsx +504 -0
- package/src/WireOnboarding.tsx +248 -0
- package/src/analytics/analyticsEvent.ts +69 -0
- package/src/analytics/reportClientEvent.ts +119 -0
- package/src/analytics/sendPreview.ts +73 -0
- package/src/attribution/attribution.ts +46 -0
- package/src/cards/ChipSelectCard.tsx +265 -0
- package/src/cards/InterstitialCard.tsx +299 -0
- package/src/cards/NumberStepperCard.tsx +121 -0
- package/src/cards/SelectionCard.tsx +270 -0
- package/src/cards/StatusCard.tsx +170 -0
- package/src/cards/TextInputCard.tsx +147 -0
- package/src/cards/index.ts +23 -0
- package/src/coachmarks/CoachmarkOverlayHost.tsx +44 -0
- package/src/coachmarks/CoachmarkProvider.tsx +108 -0
- package/src/coachmarks/GestureHint.tsx +331 -0
- package/src/coachmarks/SpotlightOverlay.tsx +254 -0
- package/src/coachmarks/coachmarkAnchorRegistry.ts +70 -0
- package/src/coachmarks/coachmarkOverlayStore.ts +63 -0
- package/src/coachmarks/index.ts +62 -0
- package/src/coachmarks/runtime.ts +103 -0
- package/src/coachmarks/selectTourSteps.ts +45 -0
- package/src/coachmarks/types.ts +58 -0
- package/src/coachmarks/useCoachmarkAnchor.ts +49 -0
- package/src/coachmarks/useCoachmarkTour.ts +200 -0
- package/src/components/AnimatedSparkle.tsx +101 -0
- package/src/components/Button.tsx +129 -0
- package/src/components/CardHandoff.tsx +212 -0
- package/src/components/CardLayout.tsx +103 -0
- package/src/components/CenteredModal.tsx +184 -0
- package/src/components/CompletionView.tsx +186 -0
- package/src/components/DoneBlock.tsx +37 -0
- package/src/components/ErrorBlock.tsx +42 -0
- package/src/components/Illustration.tsx +36 -0
- package/src/components/LoadingBlock.tsx +182 -0
- package/src/components/LoadingScreen.tsx +35 -0
- package/src/components/OnboardingScaffold.tsx +105 -0
- package/src/components/StepProgress.tsx +85 -0
- package/src/components/loaderChrome.ts +28 -0
- package/src/config/onboardingFlag.ts +39 -0
- package/src/config/wireConfigFromEnv.ts +52 -0
- package/src/device/deviceContext.ts +158 -0
- package/src/features/WireFeaturesProvider.tsx +81 -0
- package/src/features/cache.ts +92 -0
- package/src/features/defaults.ts +67 -0
- package/src/features/fetchWireFeatures.ts +86 -0
- package/src/features/index.ts +39 -0
- package/src/features/types.ts +61 -0
- package/src/features/useWireFeatures.ts +57 -0
- package/src/identity/userIdentity.ts +100 -0
- package/src/illustrations/defaultIllustrations.tsx +99 -0
- package/src/index.ts +159 -0
- package/src/motion/centeredModalMotion.ts +45 -0
- package/src/motion/motionSpec.ts +133 -0
- package/src/motion/useReducedMotion.ts +43 -0
- package/src/questionnaire/QuestionnaireGate.tsx +235 -0
- package/src/questionnaire/decision.ts +63 -0
- package/src/questionnaire/index.ts +54 -0
- package/src/questionnaire/runtime.ts +20 -0
- package/src/questionnaire/transport.ts +83 -0
- package/src/questionnaire/types.ts +182 -0
- package/src/questionnaire/useQuestionnaireGate.ts +159 -0
- package/src/reviews/ReviewGate.tsx +316 -0
- package/src/reviews/ReviewModal.tsx +13 -0
- package/src/reviews/decision.ts +120 -0
- package/src/reviews/equality.ts +54 -0
- package/src/reviews/expo-store-review.d.ts +16 -0
- package/src/reviews/feedbackForm.ts +41 -0
- package/src/reviews/index.ts +78 -0
- package/src/reviews/presentation.ts +29 -0
- package/src/reviews/runtime.ts +45 -0
- package/src/reviews/storeReview.ts +62 -0
- package/src/reviews/transport.ts +89 -0
- package/src/reviews/types.ts +172 -0
- package/src/reviews/useReviewGate.ts +137 -0
- package/src/session/persistedSession.ts +143 -0
- package/src/session-analytics/index.ts +18 -0
- package/src/session-analytics/reportSessionStart.ts +144 -0
- package/src/session-analytics/useSessionStart.ts +114 -0
- package/src/showcase/FeatureShowcase.tsx +233 -0
- package/src/showcase/index.ts +19 -0
- package/src/showcase/selectShowcaseSlides.ts +45 -0
- package/src/showcase/showcaseColors.ts +91 -0
- package/src/showcase/types.ts +74 -0
- package/src/theme/ThemeContext.tsx +38 -0
- package/src/theme/defaultTheme.ts +70 -0
- package/src/theme/themeFromBrand.ts +93 -0
- package/src/theme/types.ts +88 -0
- package/src/theme/typography.ts +46 -0
- package/src/types.ts +215 -0
- package/src/utils/deriveAnswers.ts +43 -0
- 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";
|