@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,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LoadingScreen — the STANDALONE loader surface: a full-area safe frame that centers a
|
|
3
|
+
* LoadingBlock dead in the middle of the screen. Used for the loader slots that render on
|
|
4
|
+
* their own (no OnboardingScaffold around them): the cold-start "getting started" wait, the
|
|
5
|
+
* resumed-session "restoring" wait, and the transient error/timeout retry beat.
|
|
6
|
+
*
|
|
7
|
+
* WHY a dedicated frame: those slots used to return a bare <LoadingBlock/>, whose flex:1 only
|
|
8
|
+
* fills whatever the host happened to give it — with no safe area and, if the host didn't
|
|
9
|
+
* bound it, no height at all (top-anchored). This wraps them in the same SafeAreaView the flow
|
|
10
|
+
* uses, so the standalone loaders center exactly like the between-turns loader (which centers
|
|
11
|
+
* in the full flow area via the scaffold's loaderChrome offset). One consistent centered look.
|
|
12
|
+
*/
|
|
13
|
+
import React from "react";
|
|
14
|
+
import { StyleSheet } from "react-native";
|
|
15
|
+
import { SafeAreaView } from "react-native-safe-area-context";
|
|
16
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
17
|
+
import { LoadingBlock, type LoadingBlockProps } from "./LoadingBlock";
|
|
18
|
+
|
|
19
|
+
const _LoadingScreen: React.FC<LoadingBlockProps> = ({ title, hint }) => {
|
|
20
|
+
const t = useOnboardingTheme();
|
|
21
|
+
return (
|
|
22
|
+
<SafeAreaView
|
|
23
|
+
edges={["top", "bottom"]}
|
|
24
|
+
style={[styles.flex, { backgroundColor: t.colors.background }]}
|
|
25
|
+
>
|
|
26
|
+
<LoadingBlock title={title} hint={hint} />
|
|
27
|
+
</SafeAreaView>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const LoadingScreen = React.memo(_LoadingScreen);
|
|
32
|
+
|
|
33
|
+
const styles = StyleSheet.create({
|
|
34
|
+
flex: { flex: 1 },
|
|
35
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OnboardingScaffold — the themed shell that wraps every step:
|
|
3
|
+
* [ safe area ]
|
|
4
|
+
* header -> StepProgress (a numberless fill — never "Step X of N") + optional Skip
|
|
5
|
+
* body -> the rendered card (children), full-screen (flex: 1)
|
|
6
|
+
* footer -> optional Back link
|
|
7
|
+
*
|
|
8
|
+
* WHY a single scaffold: the body content changes every turn (it's an
|
|
9
|
+
* SDK-rendered card), but the chrome — progress, skip, back, padding, safe area —
|
|
10
|
+
* stays constant. Keeping it here means cards never re-implement the frame.
|
|
11
|
+
*
|
|
12
|
+
* The body no longer centers/scrolls the card: each card fills the body via the
|
|
13
|
+
* shared CardLayout, which owns its own scroll + bottom-pinned CTA (Cal-AI style).
|
|
14
|
+
* The safe area uses `react-native-safe-area-context` (top + bottom edges) — the
|
|
15
|
+
* RN core SafeAreaView is deprecated and iOS-only, so the kit takes the standard
|
|
16
|
+
* Expo/RN peer instead of shipping cropped layouts on Android + notched devices.
|
|
17
|
+
*/
|
|
18
|
+
import React from "react";
|
|
19
|
+
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
|
20
|
+
import { SafeAreaView } from "react-native-safe-area-context";
|
|
21
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
22
|
+
import { bodyStyle } from "../theme/typography";
|
|
23
|
+
import { StepProgress } from "./StepProgress";
|
|
24
|
+
import { LoaderChromeProvider } from "./loaderChrome";
|
|
25
|
+
|
|
26
|
+
// The header's fixed StepProgress bar height (design bar = 6px; see StepProgress styles).
|
|
27
|
+
const PROGRESS_TRACK = 6;
|
|
28
|
+
|
|
29
|
+
export type OnboardingScaffoldProps = {
|
|
30
|
+
/** 1-based index of the current step; drives the (countless) progress bar. */
|
|
31
|
+
step: number;
|
|
32
|
+
/** When true, the progress bar fills to 100% (flow finished). */
|
|
33
|
+
complete?: boolean;
|
|
34
|
+
/** Approximate total screens, if known — paces the bar (never shown). */
|
|
35
|
+
approxScreens?: number;
|
|
36
|
+
/** Optional Back action — hidden when omitted (e.g. on the first step). */
|
|
37
|
+
onBack?: () => void;
|
|
38
|
+
/** Optional Skip action — hidden when omitted. */
|
|
39
|
+
onSkip?: () => void;
|
|
40
|
+
skipLabel?: string;
|
|
41
|
+
backLabel?: string;
|
|
42
|
+
children: React.ReactNode;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const _OnboardingScaffold: React.FC<OnboardingScaffoldProps> = ({
|
|
46
|
+
step,
|
|
47
|
+
complete,
|
|
48
|
+
approxScreens,
|
|
49
|
+
onBack,
|
|
50
|
+
onSkip,
|
|
51
|
+
skipLabel = "Skip",
|
|
52
|
+
backLabel = "Back",
|
|
53
|
+
children,
|
|
54
|
+
}) => {
|
|
55
|
+
const t = useOnboardingTheme();
|
|
56
|
+
const hit = { top: 12, bottom: 12, left: 12, right: 12 };
|
|
57
|
+
|
|
58
|
+
// Top chrome above the body content = header (progress bar + its paddingTop) + the body's
|
|
59
|
+
// own paddingTop. A loader that fills the body centers half that distance too low; it reads
|
|
60
|
+
// this offset (via LoaderChromeProvider) and shifts UP by half the chrome to land at the true
|
|
61
|
+
// screen center. Cards ignore it, so their layout — and the card↔loader handoff — is untouched.
|
|
62
|
+
const topChrome = t.spacing.md + PROGRESS_TRACK + t.spacing.md;
|
|
63
|
+
const loaderOffsetY = -Math.round(topChrome / 2);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<SafeAreaView
|
|
67
|
+
edges={["top", "bottom"]}
|
|
68
|
+
style={[styles.flex, { backgroundColor: t.colors.background }]}
|
|
69
|
+
>
|
|
70
|
+
<View style={[styles.header, { paddingHorizontal: t.spacing.md, paddingTop: t.spacing.md }]}>
|
|
71
|
+
<View style={styles.progressWrap}>
|
|
72
|
+
<StepProgress step={step} complete={complete} approxScreens={approxScreens} />
|
|
73
|
+
</View>
|
|
74
|
+
{onSkip ? (
|
|
75
|
+
<TouchableOpacity onPress={onSkip} hitSlop={hit} style={{ marginLeft: t.spacing.md }}>
|
|
76
|
+
<Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted }]}>{skipLabel}</Text>
|
|
77
|
+
</TouchableOpacity>
|
|
78
|
+
) : null}
|
|
79
|
+
</View>
|
|
80
|
+
|
|
81
|
+
{/* Full-screen body: the card (via CardLayout) fills this and pins its own CTA. */}
|
|
82
|
+
<View style={[styles.body, { paddingHorizontal: t.spacing.md, paddingTop: t.spacing.md }]}>
|
|
83
|
+
<LoaderChromeProvider value={{ offsetY: loaderOffsetY }}>{children}</LoaderChromeProvider>
|
|
84
|
+
</View>
|
|
85
|
+
|
|
86
|
+
{onBack ? (
|
|
87
|
+
<View style={[styles.footer, { paddingHorizontal: t.spacing.md, paddingBottom: t.spacing.sm }]}>
|
|
88
|
+
<TouchableOpacity onPress={onBack} hitSlop={hit}>
|
|
89
|
+
<Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted }]}>{backLabel}</Text>
|
|
90
|
+
</TouchableOpacity>
|
|
91
|
+
</View>
|
|
92
|
+
) : null}
|
|
93
|
+
</SafeAreaView>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const OnboardingScaffold = React.memo(_OnboardingScaffold);
|
|
98
|
+
|
|
99
|
+
const styles = StyleSheet.create({
|
|
100
|
+
flex: { flex: 1 },
|
|
101
|
+
header: { flexDirection: "row", alignItems: "center" },
|
|
102
|
+
progressWrap: { flex: 1 },
|
|
103
|
+
body: { flex: 1 },
|
|
104
|
+
footer: { flexDirection: "row", justifyContent: "flex-start" },
|
|
105
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StepProgress — a single themed progress bar that conveys forward motion. It never
|
|
3
|
+
* shows a "Step X of N" number (the count would read as a promise the model may not
|
|
4
|
+
* keep), but it can be *paced* two ways:
|
|
5
|
+
*
|
|
6
|
+
* - Default (no `approxScreens`): an asymptotic curve of the step index — always
|
|
7
|
+
* advances, never reaches the end until the flow actually completes.
|
|
8
|
+
* - With `approxScreens`: when the host knows roughly how many screens the flow
|
|
9
|
+
* runs (e.g. the backend's screen budget), the fill tracks `step / approxScreens`
|
|
10
|
+
* (capped below 100% until `complete`). More truthful pacing, still no number.
|
|
11
|
+
*/
|
|
12
|
+
import React, { useEffect, useRef } from "react";
|
|
13
|
+
import { Animated, Easing, StyleSheet, View } from "react-native";
|
|
14
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
15
|
+
import { PROGRESS_MS, WIRE_BEZIER, scaledMs, stateChangeDuration } from "../motion/motionSpec";
|
|
16
|
+
import { useReducedMotion } from "../motion/useReducedMotion";
|
|
17
|
+
|
|
18
|
+
const easeWire = Easing.bezier(...WIRE_BEZIER);
|
|
19
|
+
|
|
20
|
+
export type StepProgressProps = {
|
|
21
|
+
/** 1-based index of the current step. Drives the fill. */
|
|
22
|
+
step: number;
|
|
23
|
+
/** When true, fill to 100% (flow finished). */
|
|
24
|
+
complete?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Approximate total number of screens, if known (e.g. the backend screen budget).
|
|
27
|
+
* Paces the bar as `step / approxScreens` instead of the asymptotic curve. Never
|
|
28
|
+
* shown as a number — it only shapes the fill. Omit for the asymptotic default.
|
|
29
|
+
*/
|
|
30
|
+
approxScreens?: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Asymptotic default: each step closes ~38% of the remaining gap, so the bar always
|
|
34
|
+
// moves but never hits the end on its own: step1≈38%, step2≈62%, step3≈76%…
|
|
35
|
+
const DECAY = 0.62;
|
|
36
|
+
const MIN_FILL = 0.08;
|
|
37
|
+
// When pacing by an approximate total, never look finished until `complete`.
|
|
38
|
+
const APPROX_CAP = 0.95;
|
|
39
|
+
|
|
40
|
+
const targetFor = (step: number, complete?: boolean, approxScreens?: number): number => {
|
|
41
|
+
if (complete) return 1;
|
|
42
|
+
const s = Number.isFinite(step) && step > 0 ? Math.floor(step) : 1;
|
|
43
|
+
if (approxScreens && approxScreens > 0) {
|
|
44
|
+
return Math.max(MIN_FILL, Math.min(APPROX_CAP, s / approxScreens));
|
|
45
|
+
}
|
|
46
|
+
return Math.max(MIN_FILL, 1 - DECAY ** s);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const _StepProgress: React.FC<StepProgressProps> = ({ step, complete, approxScreens }) => {
|
|
50
|
+
const t = useOnboardingTheme();
|
|
51
|
+
const reduced = useReducedMotion();
|
|
52
|
+
const fill = useRef(new Animated.Value(targetFor(step, complete, approxScreens))).current;
|
|
53
|
+
|
|
54
|
+
// 400ms ease-wire per the design spec (design-refs/EXTRACTED-SPEC.md row 21).
|
|
55
|
+
// A growing total re-targets the same value, so the fill eases forward and never
|
|
56
|
+
// snaps back. Reduce motion: jump straight to the ratio.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
Animated.timing(fill, {
|
|
59
|
+
toValue: targetFor(step, complete, approxScreens),
|
|
60
|
+
duration: scaledMs(stateChangeDuration(reduced, PROGRESS_MS)),
|
|
61
|
+
easing: easeWire,
|
|
62
|
+
useNativeDriver: false, // animating width %
|
|
63
|
+
}).start();
|
|
64
|
+
}, [fill, step, complete, approxScreens, reduced]);
|
|
65
|
+
|
|
66
|
+
const width = fill.interpolate({ inputRange: [0, 1], outputRange: ["0%", "100%"] });
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<View style={[styles.track, { backgroundColor: t.colors.border, borderRadius: t.radius.full }]}>
|
|
70
|
+
<Animated.View
|
|
71
|
+
style={[
|
|
72
|
+
styles.fill,
|
|
73
|
+
{ width, backgroundColor: t.colors.primary, borderRadius: t.radius.full },
|
|
74
|
+
]}
|
|
75
|
+
/>
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const StepProgress = React.memo(_StepProgress);
|
|
81
|
+
|
|
82
|
+
const styles = StyleSheet.create({
|
|
83
|
+
track: { height: 6, width: "100%", overflow: "hidden" },
|
|
84
|
+
fill: { height: 6 },
|
|
85
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loaderChrome — the seam that lets a loader center in the FULL flow area instead of
|
|
3
|
+
* the header-offset body it happens to render into.
|
|
4
|
+
*
|
|
5
|
+
* The between-turns loader lives inside the OnboardingScaffold body (so the card↔loader
|
|
6
|
+
* handoff stays one coordinated CardHandoff — see OnboardingFlow). But the body sits
|
|
7
|
+
* BELOW the header (the StepProgress bar), so a loader that only fills the body centers
|
|
8
|
+
* a little low — the "not centralized in the screen" device finding. The scaffold knows
|
|
9
|
+
* its own top chrome, so it publishes a vertical offset here; LoadingBlock reads it and
|
|
10
|
+
* shifts its centered content up by that much, landing it at the true screen center
|
|
11
|
+
* WITHOUT moving the body (the card underneath is untouched, so the handoff never jumps).
|
|
12
|
+
*
|
|
13
|
+
* Default 0: a LoadingBlock rendered OUTSIDE a scaffold (the standalone LoadingScreen used
|
|
14
|
+
* for cold-start / restoring / error) has no header above it, so it already centers in the
|
|
15
|
+
* full area and needs no shift.
|
|
16
|
+
*/
|
|
17
|
+
import { createContext, useContext } from "react";
|
|
18
|
+
|
|
19
|
+
export type LoaderChrome = {
|
|
20
|
+
/** Vertical shift (px, usually negative = up) that re-centers a loader against the full flow area. */
|
|
21
|
+
offsetY: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const LoaderChromeContext = createContext<LoaderChrome>({ offsetY: 0 });
|
|
25
|
+
|
|
26
|
+
export const LoaderChromeProvider = LoaderChromeContext.Provider;
|
|
27
|
+
|
|
28
|
+
export const useLoaderChrome = (): LoaderChrome => useContext(LoaderChromeContext);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* isOnboardingEnabled — the ONE standard gate for AI onboarding, so every app stops
|
|
3
|
+
* re-authoring `flag && hasConfig` (one app had `isAiOnboardingEnabled`, another had
|
|
4
|
+
* `isWireBackendConfigured` — same idea, two shapes).
|
|
5
|
+
*
|
|
6
|
+
* The gate is **transport presence**: `wireConfigFromEnv()` is non-null (the key + URL are
|
|
7
|
+
* set), and that is it. There is no separate on/off env flag — a missing key already means
|
|
8
|
+
* "off", so a second `EXPO_PUBLIC_AI_ONBOARDING_ENABLED` flag was redundant (and, read via
|
|
9
|
+
* computed access, it didn't even inline on device). An optional `remote` kill-switch lets an
|
|
10
|
+
* app turn it off without a release; the kit stays dependency-free (the app owns the remote
|
|
11
|
+
* read and hands us the boolean).
|
|
12
|
+
*
|
|
13
|
+
* Enabled ⇔ transport present AND `(remote ?? true)`.
|
|
14
|
+
*
|
|
15
|
+
* if (!isOnboardingEnabled()) return goToStaticOnboarding();
|
|
16
|
+
* // or, with a remote kill-switch the app already read:
|
|
17
|
+
* if (!isOnboardingEnabled({ remote: remoteKillSwitchOn })) return goToStaticOnboarding();
|
|
18
|
+
*/
|
|
19
|
+
import { wireConfigFromEnv } from "./wireConfigFromEnv";
|
|
20
|
+
import type { WireConfigOverrides } from "./wireConfigFromEnv";
|
|
21
|
+
|
|
22
|
+
export type OnboardingFlagOptions = {
|
|
23
|
+
/**
|
|
24
|
+
* Optional remote kill-switch the app already read (Firebase/LD/etc.). When `false`, it
|
|
25
|
+
* disables onboarding even though transport is present. Omit (or `true`) to leave it on.
|
|
26
|
+
*/
|
|
27
|
+
remote?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Config overrides forwarded to `wireConfigFromEnv` for the transport-present check
|
|
30
|
+
* (e.g. a programmatic `apiKey`/`serverUrl`). Usually omitted.
|
|
31
|
+
*/
|
|
32
|
+
config?: WireConfigOverrides;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const isOnboardingEnabled = (opts?: OnboardingFlagOptions): boolean => {
|
|
36
|
+
const transportPresent = wireConfigFromEnv(opts?.config) !== null;
|
|
37
|
+
if (!transportPresent) return false;
|
|
38
|
+
return opts?.remote ?? true;
|
|
39
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wireConfigFromEnv — stop re-authoring env gating in every app. Reads the Wire AI
|
|
3
|
+
* tenant key + server URL from the standard Expo public env vars and returns a
|
|
4
|
+
* ready `WireOnboardingConfig`, or `null` when the key/URL are missing (so a host
|
|
5
|
+
* can fall through to a static onboarding without writing the `if (!key)` itself).
|
|
6
|
+
*
|
|
7
|
+
* EXPO_PUBLIC_WIREAI_API_KEY=... (required)
|
|
8
|
+
* EXPO_PUBLIC_WIREAI_SERVER_URL=... (required)
|
|
9
|
+
*
|
|
10
|
+
* const config = wireConfigFromEnv({ appId: "morrow" });
|
|
11
|
+
* if (!config) return <StaticOnboarding />;
|
|
12
|
+
* return <WireOnboarding config={config} ... />;
|
|
13
|
+
*
|
|
14
|
+
* `appId` defaults to `EXPO_PUBLIC_WIREAI_APP_ID` (or "default"); pass overrides to
|
|
15
|
+
* set `appId`/`metadata` or to substitute the key/URL programmatically.
|
|
16
|
+
*/
|
|
17
|
+
import type { WireOnboardingConfig } from "../types";
|
|
18
|
+
|
|
19
|
+
export type WireConfigOverrides = Partial<WireOnboardingConfig>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Minimal `process.env` declaration so the kit stays RN-pure (no `@types/node`).
|
|
23
|
+
* In RN/Expo, `process.env.EXPO_PUBLIC_*` is provided/inlined by Metro at build
|
|
24
|
+
* time; we only read string env vars here.
|
|
25
|
+
*
|
|
26
|
+
* IMPORTANT: read each var via STATIC member access (`process.env.EXPO_PUBLIC_FOO`).
|
|
27
|
+
* Expo's `babel-preset-expo` `inline-env-vars` plugin ONLY inlines static
|
|
28
|
+
* `process.env.EXPO_PUBLIC_*` member expressions. Aliasing (`const env = process.env`)
|
|
29
|
+
* or computed access (`process.env[name]`) is left `undefined` in the Metro bundle, so
|
|
30
|
+
* the value reads fine in Jest (Node) but is null on a real device. Do not refactor
|
|
31
|
+
* these to an alias.
|
|
32
|
+
*/
|
|
33
|
+
declare const process: { env: Record<string, string | undefined> };
|
|
34
|
+
|
|
35
|
+
export const wireConfigFromEnv = (
|
|
36
|
+
overrides?: WireConfigOverrides,
|
|
37
|
+
): WireOnboardingConfig | null => {
|
|
38
|
+
const apiKey = overrides?.apiKey ?? process.env.EXPO_PUBLIC_WIREAI_API_KEY;
|
|
39
|
+
const serverUrl = overrides?.serverUrl ?? process.env.EXPO_PUBLIC_WIREAI_SERVER_URL;
|
|
40
|
+
const appId =
|
|
41
|
+
overrides?.appId ?? process.env.EXPO_PUBLIC_WIREAI_APP_ID ?? "default";
|
|
42
|
+
|
|
43
|
+
// Missing transport → null, so the host gates on one falsy check.
|
|
44
|
+
if (!apiKey || !serverUrl) return null;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
apiKey,
|
|
48
|
+
serverUrl,
|
|
49
|
+
appId,
|
|
50
|
+
...(overrides?.metadata ? { metadata: overrides.metadata } : {}),
|
|
51
|
+
};
|
|
52
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deviceContext — collect a small, privacy-label-neutral snapshot of the device so
|
|
3
|
+
* onboarding analytics can segment the funnel (platform / form factor / locale) WITHOUT
|
|
4
|
+
* adding a single dependency to the kit or changing a host app's App Privacy / Data Safety
|
|
5
|
+
* declarations.
|
|
6
|
+
*
|
|
7
|
+
* HARD RULE (why this file has no imports beyond React Native built-ins):
|
|
8
|
+
* The kit stays dependency-free. Everything here comes from `Platform`, `Dimensions`,
|
|
9
|
+
* `I18nManager`, and the standard `Intl` global. There are NO advertising IDs, NO
|
|
10
|
+
* `getUniqueId`/IDFA/GAID/fingerprinting APIs, and nothing that would require a new
|
|
11
|
+
* privacy-label entry. A host can adopt this without touching its store declarations.
|
|
12
|
+
*
|
|
13
|
+
* DEFENSIVE BY DESIGN: `collectDeviceContext()` never throws. Every read is guarded and
|
|
14
|
+
* a missing/unavailable field is simply omitted (Hermes may ship without full `Intl`,
|
|
15
|
+
* `Platform.constants` differs per OS and RN version, etc.). Analytics must never be able
|
|
16
|
+
* to break onboarding.
|
|
17
|
+
*/
|
|
18
|
+
import { Dimensions, I18nManager, Platform } from "react-native";
|
|
19
|
+
|
|
20
|
+
/** Coarse device class. iOS uses the reported interface idiom; else a screen-size heuristic. */
|
|
21
|
+
export type DeviceFormFactor = "phone" | "tablet";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A privacy-label-neutral device snapshot. ALL fields except `platform` are optional and are
|
|
25
|
+
* omitted when unavailable. Nothing here identifies a user or device uniquely.
|
|
26
|
+
*/
|
|
27
|
+
export type DeviceContext = {
|
|
28
|
+
/** `Platform.OS` — "ios" | "android" | "windows" | "macos" | "web". Always present. */
|
|
29
|
+
platform: typeof Platform.OS;
|
|
30
|
+
/** OS version string (iOS `osVersion`/`Platform.Version`, Android `Release`). */
|
|
31
|
+
osVersion?: string;
|
|
32
|
+
/** Android device brand (e.g. "samsung"). Android only. */
|
|
33
|
+
brand?: string;
|
|
34
|
+
/** Android device model (e.g. "SM-G991B"). Android only. */
|
|
35
|
+
model?: string;
|
|
36
|
+
/** iOS interface idiom ("phone" | "pad" | …), when reported. iOS only. */
|
|
37
|
+
interfaceIdiom?: string;
|
|
38
|
+
/** Derived device class. */
|
|
39
|
+
formFactor?: DeviceFormFactor;
|
|
40
|
+
/** `Dimensions.get('screen')` width in dp. */
|
|
41
|
+
screenWidth?: number;
|
|
42
|
+
/** `Dimensions.get('screen')` height in dp. */
|
|
43
|
+
screenHeight?: number;
|
|
44
|
+
/** Screen pixel density (`scale`). */
|
|
45
|
+
screenScale?: number;
|
|
46
|
+
/** Right-to-left layout (`I18nManager.isRTL`). */
|
|
47
|
+
isRTL?: boolean;
|
|
48
|
+
/** Resolved locale (e.g. "en-US"), from `Intl` when available. */
|
|
49
|
+
locale?: string;
|
|
50
|
+
/** IANA time zone (e.g. "Europe/Berlin"), from `Intl` when available. */
|
|
51
|
+
timeZone?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Host app version (e.g. "1.4.2). HOST-INJECTED — NOT collected here. `WireOnboarding`
|
|
54
|
+
* merges `config.appVersion` into the snapshot; `collectDeviceContext()` never sets it.
|
|
55
|
+
* Hosts typically pass it from `expo-constants` (the kit itself adds no dependency).
|
|
56
|
+
*/
|
|
57
|
+
appVersion?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** iOS idiom wins; otherwise the shortest side in dp (>= 600 → tablet) picks the class. */
|
|
61
|
+
const deriveFormFactor = (
|
|
62
|
+
iosIdiom: string | undefined,
|
|
63
|
+
width: number | undefined,
|
|
64
|
+
height: number | undefined,
|
|
65
|
+
): DeviceFormFactor | undefined => {
|
|
66
|
+
if (iosIdiom === "pad") return "tablet";
|
|
67
|
+
if (iosIdiom === "phone") return "phone";
|
|
68
|
+
if (typeof width === "number" && typeof height === "number") {
|
|
69
|
+
return Math.min(width, height) >= 600 ? "tablet" : "phone";
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Collect the device snapshot. Pure, synchronous, and never throws — call it once per
|
|
76
|
+
* onboarding session. Missing fields are omitted rather than sent as null/undefined so the
|
|
77
|
+
* payload (and the server's stored dict) stays compact.
|
|
78
|
+
*/
|
|
79
|
+
export const collectDeviceContext = (): DeviceContext => {
|
|
80
|
+
const ctx: DeviceContext = { platform: Platform.OS };
|
|
81
|
+
|
|
82
|
+
// OS version (fallback; per-OS constants below may refine it).
|
|
83
|
+
try {
|
|
84
|
+
const version = Platform.Version;
|
|
85
|
+
if (version !== undefined && version !== null && String(version)) {
|
|
86
|
+
ctx.osVersion = String(version);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// `Platform.constants` shape differs by OS and RN version — read every field defensively.
|
|
93
|
+
let constants: Record<string, unknown> = {};
|
|
94
|
+
try {
|
|
95
|
+
constants = (Platform.constants ?? {}) as Record<string, unknown>;
|
|
96
|
+
} catch {
|
|
97
|
+
constants = {};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let iosIdiom: string | undefined;
|
|
101
|
+
try {
|
|
102
|
+
if (Platform.OS === "android") {
|
|
103
|
+
const brand = constants.Brand;
|
|
104
|
+
const model = constants.Model;
|
|
105
|
+
const release = constants.Release;
|
|
106
|
+
if (typeof brand === "string" && brand) ctx.brand = brand;
|
|
107
|
+
if (typeof model === "string" && model) ctx.model = model;
|
|
108
|
+
if (release !== undefined && release !== null && String(release)) {
|
|
109
|
+
ctx.osVersion = String(release);
|
|
110
|
+
}
|
|
111
|
+
} else if (Platform.OS === "ios") {
|
|
112
|
+
const osVersion = constants.osVersion;
|
|
113
|
+
const idiom = constants.interfaceIdiom;
|
|
114
|
+
if (osVersion !== undefined && osVersion !== null && String(osVersion)) {
|
|
115
|
+
ctx.osVersion = String(osVersion);
|
|
116
|
+
}
|
|
117
|
+
if (typeof idiom === "string" && idiom) {
|
|
118
|
+
ctx.interfaceIdiom = idiom;
|
|
119
|
+
iosIdiom = idiom;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// ignore per-OS constant reads
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Screen dimensions + derived form factor.
|
|
127
|
+
try {
|
|
128
|
+
const screen = Dimensions.get("screen");
|
|
129
|
+
if (screen) {
|
|
130
|
+
if (typeof screen.width === "number") ctx.screenWidth = screen.width;
|
|
131
|
+
if (typeof screen.height === "number") ctx.screenHeight = screen.height;
|
|
132
|
+
if (typeof screen.scale === "number") ctx.screenScale = screen.scale;
|
|
133
|
+
const formFactor = deriveFormFactor(iosIdiom, screen.width, screen.height);
|
|
134
|
+
if (formFactor) ctx.formFactor = formFactor;
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// ignore
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Layout direction.
|
|
141
|
+
try {
|
|
142
|
+
ctx.isRTL = I18nManager.isRTL;
|
|
143
|
+
} catch {
|
|
144
|
+
// ignore
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Locale + time zone via the standard Intl global. Hermes may ship without full Intl,
|
|
148
|
+
// so referencing it can throw ReferenceError — the try/catch covers that too.
|
|
149
|
+
try {
|
|
150
|
+
const resolved = Intl.DateTimeFormat().resolvedOptions();
|
|
151
|
+
if (resolved.locale) ctx.locale = resolved.locale;
|
|
152
|
+
if (resolved.timeZone) ctx.timeZone = resolved.timeZone;
|
|
153
|
+
} catch {
|
|
154
|
+
// Intl unavailable — omit locale/timeZone.
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return ctx;
|
|
158
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WireFeaturesProvider + useResolvedFeatures — the OPTIONAL one-fetch-serves-all-modules layer.
|
|
3
|
+
*
|
|
4
|
+
* Mount `WireFeaturesProvider` once near your app root with the tenant `config` and it fetches the
|
|
5
|
+
* flags ONCE; every gated surface below it (coachmarks / showcase / reviews) reads the same
|
|
6
|
+
* resolved value from context instead of each fetching its own. It is entirely optional: with no
|
|
7
|
+
* provider, every surface falls back to its own lazy fetch (when given a config) or to the all-on
|
|
8
|
+
* defaults, so modules work with zero setup and a host that never adopts flags is unchanged.
|
|
9
|
+
*
|
|
10
|
+
* NOTE on the built npm subpaths: React context is shared across the kit's subpath entries in the
|
|
11
|
+
* SOURCE-consumption path (the monorepo apps) and in this test suite — one module instance, one
|
|
12
|
+
* context. If you consume the built `wireai-onboarding/*` subpaths as separate bundles, pass the
|
|
13
|
+
* resolved flags (or a `featuresConfig`) to each surface explicitly instead of relying on context.
|
|
14
|
+
*/
|
|
15
|
+
import React, { createContext, useContext, useMemo } from "react";
|
|
16
|
+
|
|
17
|
+
import { defaultWireFeatures } from "./defaults";
|
|
18
|
+
import { useWireFeatures } from "./useWireFeatures";
|
|
19
|
+
import type { WireFeatures, WireFeaturesConfig } from "./types";
|
|
20
|
+
|
|
21
|
+
/** null = no provider mounted (the signal `useResolvedFeatures` uses to fall back). */
|
|
22
|
+
const WireFeaturesContext = createContext<WireFeatures | null>(null);
|
|
23
|
+
|
|
24
|
+
export interface WireFeaturesProviderProps {
|
|
25
|
+
/** Tenant creds (+ optional storage) to fetch the flags once for the whole tree. */
|
|
26
|
+
config?: WireFeaturesConfig;
|
|
27
|
+
/**
|
|
28
|
+
* Pre-resolved flags to provide directly instead of fetching (e.g. you already have them, or
|
|
29
|
+
* you drive them from your own remote-config). Takes precedence over `config`.
|
|
30
|
+
*/
|
|
31
|
+
flags?: WireFeatures;
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fetch (or accept) the flags once and broadcast them to descendants. The provider VALUE is
|
|
37
|
+
* memoized (performance rule #6) so a fresh object each render doesn't re-render every consumer.
|
|
38
|
+
*/
|
|
39
|
+
export const WireFeaturesProvider: React.FC<WireFeaturesProviderProps> = ({
|
|
40
|
+
config,
|
|
41
|
+
flags,
|
|
42
|
+
children,
|
|
43
|
+
}) => {
|
|
44
|
+
// Always call the hook (rules of hooks); when `flags` is supplied we pass no config so it
|
|
45
|
+
// never fetches and the explicit flags win below.
|
|
46
|
+
const fetched = useWireFeatures(flags ? undefined : config);
|
|
47
|
+
const value = flags ?? fetched;
|
|
48
|
+
const stable = useMemo(() => value, [value]);
|
|
49
|
+
return <WireFeaturesContext.Provider value={stable}>{children}</WireFeaturesContext.Provider>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
WireFeaturesProvider.displayName = "WireFeaturesProvider";
|
|
53
|
+
|
|
54
|
+
/** Raw context read — `null` when no `WireFeaturesProvider` is mounted above. */
|
|
55
|
+
export const useWireFeaturesContext = (): WireFeatures | null =>
|
|
56
|
+
useContext(WireFeaturesContext);
|
|
57
|
+
|
|
58
|
+
/** Options for `useResolvedFeatures`: an explicit override and/or a lazy-fetch config. */
|
|
59
|
+
export interface ResolveFeaturesOptions {
|
|
60
|
+
/** Explicit flags — win over everything (a surface's own `features` prop). */
|
|
61
|
+
flags?: WireFeatures;
|
|
62
|
+
/** Lazy-fetch config, used ONLY when there is no explicit `flags` and no provider above. */
|
|
63
|
+
config?: WireFeaturesConfig;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The resolution every gated surface uses. Precedence:
|
|
68
|
+
* 1. explicit `options.flags` (a surface's own prop) →
|
|
69
|
+
* 2. the nearest `WireFeaturesProvider` (context) →
|
|
70
|
+
* 3. a lazy fetch from `options.config` (serverUrl + apiKey) →
|
|
71
|
+
* 4. the all-on defaults.
|
|
72
|
+
* Always fail-open, always a stable reference for a given value (so it's safe in effect deps).
|
|
73
|
+
*/
|
|
74
|
+
export const useResolvedFeatures = (options?: ResolveFeaturesOptions): WireFeatures => {
|
|
75
|
+
const ctx = useWireFeaturesContext();
|
|
76
|
+
// Only lazily fetch when nothing else provides the flags — otherwise pass no config so the
|
|
77
|
+
// hook stays inert (defaults, no network). The hook is always called (rules of hooks).
|
|
78
|
+
const shouldFetch = !options?.flags && ctx == null;
|
|
79
|
+
const fetched = useWireFeatures(shouldFetch ? options?.config : undefined);
|
|
80
|
+
return options?.flags ?? ctx ?? fetched ?? defaultWireFeatures;
|
|
81
|
+
};
|