@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,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CenteredModal - the content-agnostic centered-popup shell.
|
|
3
|
+
*
|
|
4
|
+
* Renders its children as an alert-style card in the MIDDLE of the screen, over a dimmed
|
|
5
|
+
* backdrop, with a spring/fade entrance. It knows nothing about what it presents: the reviews
|
|
6
|
+
* gate uses it (via the `ReviewModal` back-compat re-export) and the questionnaire module reuses
|
|
7
|
+
* the same shell, neither depends on the other.
|
|
8
|
+
*
|
|
9
|
+
* Adapted from the mobile-launcher boilerplate's `animated-modal` (transparent RN Modal +
|
|
10
|
+
* centered container + backdrop-tap dismiss + content stop-propagation + springy zoom-in), but
|
|
11
|
+
* reimplemented on RN's built-in `Animated` (native driver) so the shell stays dependency-free:
|
|
12
|
+
* the boilerplate uses react-native-reanimated, which the kit does not.
|
|
13
|
+
*
|
|
14
|
+
* Dismissal (backdrop tap / Android back / the imperative `close()` handle) animates the card
|
|
15
|
+
* out, then fires `onDismiss`. It never auto-closes on resolve: the host keeps the card showing
|
|
16
|
+
* whatever payoff it wants (a thank-you, a summary) until the user taps the backdrop to close,
|
|
17
|
+
* exactly like CompletionView.
|
|
18
|
+
*/
|
|
19
|
+
import React, {
|
|
20
|
+
forwardRef,
|
|
21
|
+
useCallback,
|
|
22
|
+
useEffect,
|
|
23
|
+
useImperativeHandle,
|
|
24
|
+
useRef,
|
|
25
|
+
useState,
|
|
26
|
+
} from "react";
|
|
27
|
+
import { Animated, BackHandler, Easing, Modal, Pressable, StyleSheet } from "react-native";
|
|
28
|
+
import { SafeAreaView } from "react-native-safe-area-context";
|
|
29
|
+
|
|
30
|
+
import type { OnboardingTheme } from "../theme/types";
|
|
31
|
+
import { REDUCED_FADE_MS } from "../motion/motionSpec";
|
|
32
|
+
import { useReducedMotion } from "../motion/useReducedMotion";
|
|
33
|
+
import { CENTERED_MODAL_ANIM } from "../motion/centeredModalMotion";
|
|
34
|
+
|
|
35
|
+
/** Imperative handle so an in-card "Not now"/"Skip" link can trigger the exit animation. */
|
|
36
|
+
export type CenteredModalHandle = { close: () => void };
|
|
37
|
+
|
|
38
|
+
export type CenteredModalProps = {
|
|
39
|
+
/** The (already merged) active theme - card surface/radius/spacing come from here. */
|
|
40
|
+
theme: OnboardingTheme;
|
|
41
|
+
/** Whether a backdrop tap / back press dismisses. Default true (never a dead end). */
|
|
42
|
+
dismissible?: boolean;
|
|
43
|
+
/** Fired once, AFTER the exit animation, on any dismiss. Wire to the host's resolve. */
|
|
44
|
+
onDismiss: () => void;
|
|
45
|
+
children: React.ReactNode;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const _CenteredModal = forwardRef<CenteredModalHandle, CenteredModalProps>(
|
|
49
|
+
({ theme, dismissible = true, onDismiss, children }, ref) => {
|
|
50
|
+
const progress = useRef(new Animated.Value(0)).current;
|
|
51
|
+
const [open, setOpen] = useState(true);
|
|
52
|
+
const closingRef = useRef(false);
|
|
53
|
+
const reduced = useReducedMotion();
|
|
54
|
+
|
|
55
|
+
// Entrance: spring the card in (scale + fade); the backdrop fades up in lock-step.
|
|
56
|
+
// Reduce motion: a short fade to the settled card, no spring/scale.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const anim = reduced
|
|
59
|
+
? Animated.timing(progress, {
|
|
60
|
+
toValue: 1,
|
|
61
|
+
duration: REDUCED_FADE_MS,
|
|
62
|
+
useNativeDriver: true,
|
|
63
|
+
})
|
|
64
|
+
: Animated.spring(progress, {
|
|
65
|
+
toValue: 1,
|
|
66
|
+
useNativeDriver: true,
|
|
67
|
+
friction: CENTERED_MODAL_ANIM.enterSpring.friction,
|
|
68
|
+
tension: CENTERED_MODAL_ANIM.enterSpring.tension,
|
|
69
|
+
});
|
|
70
|
+
anim.start();
|
|
71
|
+
return () => anim.stop();
|
|
72
|
+
}, [progress, reduced]);
|
|
73
|
+
|
|
74
|
+
const close = useCallback(() => {
|
|
75
|
+
if (closingRef.current) return;
|
|
76
|
+
closingRef.current = true;
|
|
77
|
+
Animated.timing(progress, {
|
|
78
|
+
toValue: 0,
|
|
79
|
+
duration: reduced ? REDUCED_FADE_MS : CENTERED_MODAL_ANIM.exitMs,
|
|
80
|
+
easing: Easing.in(Easing.cubic),
|
|
81
|
+
useNativeDriver: true,
|
|
82
|
+
}).start(() => {
|
|
83
|
+
setOpen(false);
|
|
84
|
+
onDismiss();
|
|
85
|
+
});
|
|
86
|
+
}, [progress, onDismiss, reduced]);
|
|
87
|
+
|
|
88
|
+
useImperativeHandle(ref, () => ({ close }), [close]);
|
|
89
|
+
|
|
90
|
+
// Android hardware back -> dismiss (when allowed). Always consume so it never pops a route.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
|
|
93
|
+
if (dismissible) close();
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
return () => sub.remove();
|
|
97
|
+
}, [dismissible, close]);
|
|
98
|
+
|
|
99
|
+
const onBackdrop = useCallback(() => {
|
|
100
|
+
if (dismissible) close();
|
|
101
|
+
}, [dismissible, close]);
|
|
102
|
+
|
|
103
|
+
const backdropOpacity = progress.interpolate({
|
|
104
|
+
inputRange: [0, 1],
|
|
105
|
+
outputRange: [0, CENTERED_MODAL_ANIM.backdropOpacity],
|
|
106
|
+
});
|
|
107
|
+
// Reduce motion: the card fades in already settled (no zoom).
|
|
108
|
+
const cardScale = reduced
|
|
109
|
+
? 1
|
|
110
|
+
: progress.interpolate({
|
|
111
|
+
inputRange: [0, 1],
|
|
112
|
+
outputRange: [CENTERED_MODAL_ANIM.scaleFrom, 1],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<Modal
|
|
117
|
+
visible={open}
|
|
118
|
+
transparent
|
|
119
|
+
animationType="none"
|
|
120
|
+
statusBarTranslucent
|
|
121
|
+
onRequestClose={dismissible ? close : undefined}
|
|
122
|
+
>
|
|
123
|
+
{/* Dimmed backdrop (fades with the entrance). */}
|
|
124
|
+
<Animated.View
|
|
125
|
+
style={[styles.backdrop, { opacity: backdropOpacity }]}
|
|
126
|
+
pointerEvents="none"
|
|
127
|
+
/>
|
|
128
|
+
|
|
129
|
+
{/* Full-screen tap target: a tap outside the card dismisses. */}
|
|
130
|
+
<Pressable
|
|
131
|
+
style={styles.fill}
|
|
132
|
+
onPress={onBackdrop}
|
|
133
|
+
accessibilityRole="button"
|
|
134
|
+
accessibilityLabel="Dismiss"
|
|
135
|
+
>
|
|
136
|
+
<SafeAreaView style={styles.center} pointerEvents="box-none">
|
|
137
|
+
{/* Inner Pressable swallows taps so the card itself never dismisses. */}
|
|
138
|
+
<Pressable onPress={() => {}} style={styles.cardWrap}>
|
|
139
|
+
<Animated.View
|
|
140
|
+
accessibilityViewIsModal
|
|
141
|
+
accessibilityRole="alert"
|
|
142
|
+
style={[
|
|
143
|
+
styles.card,
|
|
144
|
+
{
|
|
145
|
+
backgroundColor: theme.colors.surface,
|
|
146
|
+
borderRadius: theme.radius.lg,
|
|
147
|
+
padding: theme.spacing.lg,
|
|
148
|
+
gap: theme.spacing.md,
|
|
149
|
+
opacity: progress,
|
|
150
|
+
transform: [{ scale: cardScale }],
|
|
151
|
+
},
|
|
152
|
+
]}
|
|
153
|
+
>
|
|
154
|
+
{children}
|
|
155
|
+
</Animated.View>
|
|
156
|
+
</Pressable>
|
|
157
|
+
</SafeAreaView>
|
|
158
|
+
</Pressable>
|
|
159
|
+
</Modal>
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
_CenteredModal.displayName = "CenteredModal";
|
|
165
|
+
|
|
166
|
+
export const CenteredModal = React.memo(_CenteredModal);
|
|
167
|
+
|
|
168
|
+
const styles = StyleSheet.create({
|
|
169
|
+
fill: { flex: 1 },
|
|
170
|
+
backdrop: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "#000" },
|
|
171
|
+
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
|
172
|
+
cardWrap: { width: "100%", maxWidth: 420 },
|
|
173
|
+
card: {
|
|
174
|
+
width: "100%",
|
|
175
|
+
// Soft elevation so the popup reads as lifted above the app in both themes.
|
|
176
|
+
shadowColor: "#000",
|
|
177
|
+
shadowOffset: { width: 0, height: 8 },
|
|
178
|
+
shadowOpacity: 0.28,
|
|
179
|
+
shadowRadius: 24,
|
|
180
|
+
elevation: 12,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
export default CenteredModal;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CompletionView — the terminal "you're all set" screen, shown when the flow
|
|
3
|
+
* reaches its final StatusCard. It surfaces the backend's recap (what the app
|
|
4
|
+
* will do for the user, derived from their answers) and a single clear CTA.
|
|
5
|
+
*
|
|
6
|
+
* WHY a dedicated screen (not an instant hand-off): the backend spends the last
|
|
7
|
+
* turn summarizing the user's tastes and what their experience will now look
|
|
8
|
+
* like. The user should actually SEE that payoff and tap to continue — so
|
|
9
|
+
* `onContinue` fires only on the button press, never automatically.
|
|
10
|
+
*
|
|
11
|
+
* Motion (design-refs/EXTRACTED-SPEC.md rows 14-16, the peak-end beat): the
|
|
12
|
+
* badge pops in with a spring (scale 0.4→1, slight overshoot), ONE soft accent
|
|
13
|
+
* burst radiates once behind it (scale 0.4→1.5, opacity 0.5→0, 500ms) —
|
|
14
|
+
* celebratory without confetti — and the copy fades up 80ms apart. Everything
|
|
15
|
+
* settles well under a second. Reduce motion: final frame, no burst.
|
|
16
|
+
*/
|
|
17
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
18
|
+
import { Animated, Easing, StyleSheet, Text, View } from "react-native";
|
|
19
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
20
|
+
import { bodyStyle, headingStyle } from "../theme/typography";
|
|
21
|
+
import { Button } from "./Button";
|
|
22
|
+
import { CardLayout } from "./CardLayout";
|
|
23
|
+
import {
|
|
24
|
+
STATUS_BURST_MS,
|
|
25
|
+
STATUS_BURST_OPACITY_FROM,
|
|
26
|
+
STATUS_BURST_SCALE_FROM,
|
|
27
|
+
STATUS_BURST_SCALE_TO,
|
|
28
|
+
STATUS_COPY_MS,
|
|
29
|
+
STATUS_POP_SCALE_FROM,
|
|
30
|
+
STATUS_POP_SPRING,
|
|
31
|
+
WIRE_BEZIER,
|
|
32
|
+
scaledMs,
|
|
33
|
+
statusCopyDelay,
|
|
34
|
+
} from "../motion/motionSpec";
|
|
35
|
+
import { useReducedMotion } from "../motion/useReducedMotion";
|
|
36
|
+
|
|
37
|
+
const easeWire = Easing.bezier(...WIRE_BEZIER);
|
|
38
|
+
const BADGE_SIZE = 64;
|
|
39
|
+
const BURST_SIZE = 104;
|
|
40
|
+
|
|
41
|
+
export type CompletionViewProps = {
|
|
42
|
+
title: string;
|
|
43
|
+
message?: string;
|
|
44
|
+
ctaLabel?: string;
|
|
45
|
+
onContinue: () => void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const _CompletionView: React.FC<CompletionViewProps> = ({
|
|
49
|
+
title,
|
|
50
|
+
message,
|
|
51
|
+
ctaLabel = "Let's go",
|
|
52
|
+
onContinue,
|
|
53
|
+
}) => {
|
|
54
|
+
const t = useOnboardingTheme();
|
|
55
|
+
const reduced = useReducedMotion();
|
|
56
|
+
const [submitted, setSubmitted] = useState(false);
|
|
57
|
+
|
|
58
|
+
const pop = useRef(new Animated.Value(reduced ? 1 : 0)).current;
|
|
59
|
+
const burst = useRef(new Animated.Value(reduced ? 1 : 0)).current;
|
|
60
|
+
const titleT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
|
|
61
|
+
const messageT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (reduced) {
|
|
65
|
+
// Final frame: badge + copy settled, burst already faded out.
|
|
66
|
+
pop.setValue(1);
|
|
67
|
+
burst.setValue(1);
|
|
68
|
+
titleT.setValue(1);
|
|
69
|
+
messageT.setValue(1);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const rise = (value: Animated.Value, delay: number) =>
|
|
73
|
+
Animated.timing(value, {
|
|
74
|
+
toValue: 1,
|
|
75
|
+
duration: scaledMs(STATUS_COPY_MS),
|
|
76
|
+
delay,
|
|
77
|
+
easing: easeWire,
|
|
78
|
+
useNativeDriver: true,
|
|
79
|
+
});
|
|
80
|
+
const anims = [
|
|
81
|
+
Animated.spring(pop, {
|
|
82
|
+
toValue: 1,
|
|
83
|
+
friction: STATUS_POP_SPRING.friction,
|
|
84
|
+
tension: STATUS_POP_SPRING.tension,
|
|
85
|
+
useNativeDriver: true,
|
|
86
|
+
}),
|
|
87
|
+
// One-shot celebratory burst: fires once, never loops.
|
|
88
|
+
Animated.timing(burst, {
|
|
89
|
+
toValue: 1,
|
|
90
|
+
duration: scaledMs(STATUS_BURST_MS),
|
|
91
|
+
easing: Easing.out(Easing.ease),
|
|
92
|
+
useNativeDriver: true,
|
|
93
|
+
}),
|
|
94
|
+
rise(titleT, scaledMs(statusCopyDelay(0))),
|
|
95
|
+
rise(messageT, scaledMs(statusCopyDelay(1))),
|
|
96
|
+
];
|
|
97
|
+
anims.forEach((a) => a.start());
|
|
98
|
+
return () => anims.forEach((a) => a.stop());
|
|
99
|
+
}, [reduced, pop, burst, titleT, messageT]);
|
|
100
|
+
|
|
101
|
+
const handlePress = useCallback(() => {
|
|
102
|
+
if (submitted) return;
|
|
103
|
+
setSubmitted(true);
|
|
104
|
+
onContinue();
|
|
105
|
+
}, [submitted, onContinue]);
|
|
106
|
+
|
|
107
|
+
const scale = pop.interpolate({ inputRange: [0, 1], outputRange: [STATUS_POP_SCALE_FROM, 1] });
|
|
108
|
+
const burstScale = burst.interpolate({
|
|
109
|
+
inputRange: [0, 1],
|
|
110
|
+
outputRange: [STATUS_BURST_SCALE_FROM, STATUS_BURST_SCALE_TO],
|
|
111
|
+
});
|
|
112
|
+
const burstOpacity = burst.interpolate({
|
|
113
|
+
inputRange: [0, 1],
|
|
114
|
+
outputRange: [STATUS_BURST_OPACITY_FROM, 0],
|
|
115
|
+
});
|
|
116
|
+
const riseStyle = (value: Animated.Value) => ({
|
|
117
|
+
opacity: value,
|
|
118
|
+
transform: [{ translateY: value.interpolate({ inputRange: [0, 1], outputRange: [10, 0] }) }],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<CardLayout
|
|
123
|
+
align="center"
|
|
124
|
+
footer={<Button title={ctaLabel} onPress={handlePress} variant="primary" disabled={submitted} />}
|
|
125
|
+
>
|
|
126
|
+
<View style={[styles.center, { gap: t.spacing.md }]}>
|
|
127
|
+
<View style={styles.badgeStage}>
|
|
128
|
+
{/* Soft accent burst behind the badge: primarySoft keeps the tint calm. */}
|
|
129
|
+
<Animated.View
|
|
130
|
+
pointerEvents="none"
|
|
131
|
+
style={[
|
|
132
|
+
styles.burst,
|
|
133
|
+
{
|
|
134
|
+
backgroundColor: t.colors.primarySoft,
|
|
135
|
+
borderRadius: t.radius.full,
|
|
136
|
+
opacity: burstOpacity,
|
|
137
|
+
transform: [{ scale: burstScale }],
|
|
138
|
+
},
|
|
139
|
+
]}
|
|
140
|
+
/>
|
|
141
|
+
<Animated.View
|
|
142
|
+
style={[
|
|
143
|
+
styles.badge,
|
|
144
|
+
{
|
|
145
|
+
backgroundColor: t.colors.primarySoft,
|
|
146
|
+
borderRadius: t.radius.full,
|
|
147
|
+
transform: [{ scale }],
|
|
148
|
+
opacity: pop,
|
|
149
|
+
},
|
|
150
|
+
]}
|
|
151
|
+
>
|
|
152
|
+
<Text style={[styles.check, { color: t.colors.primary }]}>✓</Text>
|
|
153
|
+
</Animated.View>
|
|
154
|
+
</View>
|
|
155
|
+
|
|
156
|
+
<Animated.View style={riseStyle(titleT)}>
|
|
157
|
+
<Text style={[headingStyle(t.fonts), { color: t.colors.text, textAlign: "center" }]}>
|
|
158
|
+
{title}
|
|
159
|
+
</Text>
|
|
160
|
+
</Animated.View>
|
|
161
|
+
{message ? (
|
|
162
|
+
<Animated.View style={riseStyle(messageT)}>
|
|
163
|
+
<Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
|
|
164
|
+
{message}
|
|
165
|
+
</Text>
|
|
166
|
+
</Animated.View>
|
|
167
|
+
) : null}
|
|
168
|
+
</View>
|
|
169
|
+
</CardLayout>
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const CompletionView = React.memo(_CompletionView);
|
|
174
|
+
|
|
175
|
+
const styles = StyleSheet.create({
|
|
176
|
+
center: { width: "100%", alignItems: "center", justifyContent: "center" },
|
|
177
|
+
badgeStage: {
|
|
178
|
+
width: BURST_SIZE,
|
|
179
|
+
height: BURST_SIZE,
|
|
180
|
+
alignItems: "center",
|
|
181
|
+
justifyContent: "center",
|
|
182
|
+
},
|
|
183
|
+
burst: { position: "absolute", width: BURST_SIZE, height: BURST_SIZE },
|
|
184
|
+
badge: { width: BADGE_SIZE, height: BADGE_SIZE, alignItems: "center", justifyContent: "center" },
|
|
185
|
+
check: { fontSize: 34, fontWeight: "800" },
|
|
186
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DoneBlock — a brief themed "all set" state shown when the flow completes, while
|
|
3
|
+
* the host persists results and navigates away. Purely cosmetic; the real
|
|
4
|
+
* terminal signal is the SDK's StatusCard (handled in OnboardingFlow).
|
|
5
|
+
*/
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { StyleSheet, Text, View } from "react-native";
|
|
8
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
9
|
+
import { bodyStyle, headingStyle } from "../theme/typography";
|
|
10
|
+
|
|
11
|
+
export type DoneBlockProps = {
|
|
12
|
+
title?: string;
|
|
13
|
+
message?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const _DoneBlock: React.FC<DoneBlockProps> = ({
|
|
17
|
+
title = "You're all set",
|
|
18
|
+
message = "Personalizing your experience…",
|
|
19
|
+
}) => {
|
|
20
|
+
const t = useOnboardingTheme();
|
|
21
|
+
return (
|
|
22
|
+
<View style={[styles.center, { gap: t.spacing.sm, padding: t.spacing.lg }]}>
|
|
23
|
+
<Text style={[headingStyle(t.fonts), { color: t.colors.success, textAlign: "center" }]}>
|
|
24
|
+
{title}
|
|
25
|
+
</Text>
|
|
26
|
+
<Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
|
|
27
|
+
{message}
|
|
28
|
+
</Text>
|
|
29
|
+
</View>
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const DoneBlock = React.memo(_DoneBlock);
|
|
34
|
+
|
|
35
|
+
const styles = StyleSheet.create({
|
|
36
|
+
center: { flex: 1, alignItems: "center", justifyContent: "center" },
|
|
37
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ErrorBlock — shown on backend error/timeout. Carries a retry action so the flow
|
|
3
|
+
* never dead-ends. The host decides what retry does (re-send start, or fall back).
|
|
4
|
+
*/
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { StyleSheet, Text, View } from "react-native";
|
|
7
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
8
|
+
import { bodyStyle, headingStyle } from "../theme/typography";
|
|
9
|
+
import { Button } from "./Button";
|
|
10
|
+
|
|
11
|
+
export type ErrorBlockProps = {
|
|
12
|
+
title?: string;
|
|
13
|
+
message?: string;
|
|
14
|
+
retryLabel?: string;
|
|
15
|
+
onRetry?: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const _ErrorBlock: React.FC<ErrorBlockProps> = ({
|
|
19
|
+
title = "Something went wrong",
|
|
20
|
+
message = "We couldn't load this step. You can try again.",
|
|
21
|
+
retryLabel = "Try again",
|
|
22
|
+
onRetry,
|
|
23
|
+
}) => {
|
|
24
|
+
const t = useOnboardingTheme();
|
|
25
|
+
return (
|
|
26
|
+
<View style={[styles.center, { gap: t.spacing.md, padding: t.spacing.lg }]}>
|
|
27
|
+
<Text style={[headingStyle(t.fonts), { color: t.colors.text, textAlign: "center" }]}>
|
|
28
|
+
{title}
|
|
29
|
+
</Text>
|
|
30
|
+
<Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
|
|
31
|
+
{message}
|
|
32
|
+
</Text>
|
|
33
|
+
{onRetry ? <Button title={retryLabel} onPress={onRetry} variant="outline" /> : null}
|
|
34
|
+
</View>
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const ErrorBlock = React.memo(_ErrorBlock);
|
|
39
|
+
|
|
40
|
+
const styles = StyleSheet.create({
|
|
41
|
+
center: { flex: 1, alignItems: "center", justifyContent: "center" },
|
|
42
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Illustration registry — lets the host app supply brand artwork (SVG / Lottie /
|
|
3
|
+
* Image nodes) keyed by name, which the backend references from an InterstitialCard
|
|
4
|
+
* by `illustration`. Keeping the registry in React context (rather than as a card
|
|
5
|
+
* prop) is what keeps the kit dependency-free: the kit never imports react-native-svg
|
|
6
|
+
* or Lottie — the app brings its own nodes and the kit just slots the matching one.
|
|
7
|
+
*
|
|
8
|
+
* <WireOnboarding illustrations={{ "before-after": <BeforeAfter/> }} ... />
|
|
9
|
+
*
|
|
10
|
+
* When the backend names an illustration the registry doesn't have, the
|
|
11
|
+
* InterstitialCard falls back to its `imageUrl` (a plain RN <Image>).
|
|
12
|
+
*/
|
|
13
|
+
import React, { createContext, useContext } from "react";
|
|
14
|
+
|
|
15
|
+
export type IllustrationRegistry = Record<string, React.ReactNode>;
|
|
16
|
+
|
|
17
|
+
const IllustrationContext = createContext<IllustrationRegistry>({});
|
|
18
|
+
|
|
19
|
+
export type IllustrationProviderProps = {
|
|
20
|
+
registry?: IllustrationRegistry;
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const IllustrationProvider: React.FC<IllustrationProviderProps> = ({
|
|
25
|
+
registry,
|
|
26
|
+
children,
|
|
27
|
+
}) => (
|
|
28
|
+
<IllustrationContext.Provider value={registry ?? {}}>{children}</IllustrationContext.Provider>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/** Look up an app-supplied illustration node by name. Returns undefined if absent. */
|
|
32
|
+
export const useIllustration = (name?: string): React.ReactNode | undefined => {
|
|
33
|
+
const registry = useContext(IllustrationContext);
|
|
34
|
+
if (!name) return undefined;
|
|
35
|
+
return registry[name];
|
|
36
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LoadingBlock — the "AI is thinking" indicator, shown before the first card and
|
|
3
|
+
* between every turn (each round-trip to the backend). The Wire Activation
|
|
4
|
+
* loading treatment: no generic spinner — the brand mark breathes inside a
|
|
5
|
+
* dashed ring while an accent dot orbits it, and skeleton lines appear (softly
|
|
6
|
+
* pulsing) only when the fetch runs past 300ms so a fast turn never flashes
|
|
7
|
+
* placeholder chrome (design-refs/EXTRACTED-SPEC.md rows 17-19).
|
|
8
|
+
*
|
|
9
|
+
* Reduce motion: static mark + copy, no orbit, no pulse.
|
|
10
|
+
*/
|
|
11
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
12
|
+
import { Animated, Easing, StyleSheet, Text, View } from "react-native";
|
|
13
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
14
|
+
import { bodyStyle, headingStyle } from "../theme/typography";
|
|
15
|
+
import { AnimatedSparkle } from "./AnimatedSparkle";
|
|
16
|
+
import {
|
|
17
|
+
LOADER_ORBIT_MS,
|
|
18
|
+
SKELETON_DELAY_MS,
|
|
19
|
+
SKELETON_MIN_OPACITY,
|
|
20
|
+
SKELETON_PULSE_MS,
|
|
21
|
+
loopEnabled,
|
|
22
|
+
scaledMs,
|
|
23
|
+
} from "../motion/motionSpec";
|
|
24
|
+
import { useReducedMotion } from "../motion/useReducedMotion";
|
|
25
|
+
import { useLoaderChrome } from "./loaderChrome";
|
|
26
|
+
|
|
27
|
+
const STAGE_SIZE = 96;
|
|
28
|
+
const ORBIT_DOT = 9;
|
|
29
|
+
|
|
30
|
+
export type LoadingBlockProps = {
|
|
31
|
+
/** Primary line, e.g. "Thinking…". */
|
|
32
|
+
title?: string;
|
|
33
|
+
/** Supporting line under the title. */
|
|
34
|
+
hint?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The orbiting accent dot: a full-stage rotating layer with the dot at 12 o'clock. */
|
|
38
|
+
const OrbitDot: React.FC<{ color: string; reduced: boolean }> = ({ color, reduced }) => {
|
|
39
|
+
const spin = useRef(new Animated.Value(0)).current;
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!loopEnabled(reduced)) return;
|
|
42
|
+
const loop = Animated.loop(
|
|
43
|
+
Animated.timing(spin, {
|
|
44
|
+
toValue: 1,
|
|
45
|
+
duration: scaledMs(LOADER_ORBIT_MS),
|
|
46
|
+
easing: Easing.linear,
|
|
47
|
+
useNativeDriver: true,
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
loop.start();
|
|
51
|
+
return () => loop.stop();
|
|
52
|
+
}, [spin, reduced]);
|
|
53
|
+
|
|
54
|
+
if (reduced) return null;
|
|
55
|
+
const rotate = spin.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] });
|
|
56
|
+
return (
|
|
57
|
+
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { transform: [{ rotate }] }]}>
|
|
58
|
+
<View style={[styles.orbitDot, { backgroundColor: color }]} />
|
|
59
|
+
</Animated.View>
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** A skeleton line that softly pulses (static under reduce motion). */
|
|
64
|
+
const SkeletonLine: React.FC<{ width: `${number}%`; delay: number; color: string; reduced: boolean }> = ({
|
|
65
|
+
width,
|
|
66
|
+
delay,
|
|
67
|
+
color,
|
|
68
|
+
reduced,
|
|
69
|
+
}) => {
|
|
70
|
+
const pulse = useRef(new Animated.Value(1)).current;
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!loopEnabled(reduced)) return;
|
|
73
|
+
const loop = Animated.loop(
|
|
74
|
+
Animated.sequence([
|
|
75
|
+
Animated.delay(delay),
|
|
76
|
+
Animated.timing(pulse, {
|
|
77
|
+
toValue: SKELETON_MIN_OPACITY,
|
|
78
|
+
duration: scaledMs(SKELETON_PULSE_MS / 2),
|
|
79
|
+
easing: Easing.inOut(Easing.ease),
|
|
80
|
+
useNativeDriver: true,
|
|
81
|
+
}),
|
|
82
|
+
Animated.timing(pulse, {
|
|
83
|
+
toValue: 1,
|
|
84
|
+
duration: scaledMs(SKELETON_PULSE_MS / 2),
|
|
85
|
+
easing: Easing.inOut(Easing.ease),
|
|
86
|
+
useNativeDriver: true,
|
|
87
|
+
}),
|
|
88
|
+
]),
|
|
89
|
+
);
|
|
90
|
+
loop.start();
|
|
91
|
+
return () => loop.stop();
|
|
92
|
+
}, [pulse, delay, reduced]);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Animated.View
|
|
96
|
+
style={[styles.skeletonLine, { width, backgroundColor: color, opacity: reduced ? 1 : pulse }]}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const _LoadingBlock: React.FC<LoadingBlockProps> = ({
|
|
102
|
+
title = "Thinking…",
|
|
103
|
+
hint = "Personalizing your experience",
|
|
104
|
+
}) => {
|
|
105
|
+
const t = useOnboardingTheme();
|
|
106
|
+
const reduced = useReducedMotion();
|
|
107
|
+
// When the loader renders inside the scaffold body it sits below the header; the scaffold
|
|
108
|
+
// publishes how far to shift UP so the loader centers in the full flow area, not the body.
|
|
109
|
+
// Standalone (no provider) → 0. A static transform (visual only): it never reflows the body,
|
|
110
|
+
// so the card underneath and the card↔loader handoff are unaffected.
|
|
111
|
+
const { offsetY } = useLoaderChrome();
|
|
112
|
+
|
|
113
|
+
// Skeleton lines are gated behind the 300ms window: a fast fetch never shows them.
|
|
114
|
+
const [showSkeleton, setShowSkeleton] = useState(false);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const timer = setTimeout(() => setShowSkeleton(true), SKELETON_DELAY_MS);
|
|
117
|
+
return () => clearTimeout(timer);
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<View
|
|
122
|
+
style={[
|
|
123
|
+
styles.center,
|
|
124
|
+
{ gap: t.spacing.md, padding: t.spacing.lg },
|
|
125
|
+
offsetY ? { transform: [{ translateY: offsetY }] } : null,
|
|
126
|
+
]}
|
|
127
|
+
>
|
|
128
|
+
<View style={styles.stage}>
|
|
129
|
+
<View style={[styles.ring, { borderColor: t.colors.border, borderRadius: t.radius.full }]} />
|
|
130
|
+
<OrbitDot color={t.colors.primary} reduced={reduced} />
|
|
131
|
+
<AnimatedSparkle size={40} variant="pulse" />
|
|
132
|
+
</View>
|
|
133
|
+
{title ? (
|
|
134
|
+
<Text style={[headingStyle(t.fonts), { color: t.colors.text, textAlign: "center" }]}>
|
|
135
|
+
{title}
|
|
136
|
+
</Text>
|
|
137
|
+
) : null}
|
|
138
|
+
{hint ? (
|
|
139
|
+
<Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
|
|
140
|
+
{hint}
|
|
141
|
+
</Text>
|
|
142
|
+
) : null}
|
|
143
|
+
{showSkeleton ? (
|
|
144
|
+
<View style={[styles.skeleton, { gap: t.spacing.sm, marginTop: t.spacing.sm }]}>
|
|
145
|
+
<SkeletonLine width="80%" delay={0} color={t.colors.surface} reduced={reduced} />
|
|
146
|
+
<SkeletonLine width="60%" delay={200} color={t.colors.surface} reduced={reduced} />
|
|
147
|
+
</View>
|
|
148
|
+
) : null}
|
|
149
|
+
</View>
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const LoadingBlock = React.memo(_LoadingBlock);
|
|
154
|
+
|
|
155
|
+
const styles = StyleSheet.create({
|
|
156
|
+
center: { flex: 1, alignItems: "center", justifyContent: "center" },
|
|
157
|
+
stage: {
|
|
158
|
+
width: STAGE_SIZE,
|
|
159
|
+
height: STAGE_SIZE,
|
|
160
|
+
alignItems: "center",
|
|
161
|
+
justifyContent: "center",
|
|
162
|
+
},
|
|
163
|
+
ring: {
|
|
164
|
+
position: "absolute",
|
|
165
|
+
top: 0,
|
|
166
|
+
left: 0,
|
|
167
|
+
right: 0,
|
|
168
|
+
bottom: 0,
|
|
169
|
+
borderWidth: 1.5,
|
|
170
|
+
borderStyle: "dashed",
|
|
171
|
+
},
|
|
172
|
+
orbitDot: {
|
|
173
|
+
position: "absolute",
|
|
174
|
+
top: -ORBIT_DOT / 2 + 1,
|
|
175
|
+
left: STAGE_SIZE / 2 - ORBIT_DOT / 2,
|
|
176
|
+
width: ORBIT_DOT,
|
|
177
|
+
height: ORBIT_DOT,
|
|
178
|
+
borderRadius: ORBIT_DOT / 2,
|
|
179
|
+
},
|
|
180
|
+
skeleton: { width: "100%", alignItems: "center" },
|
|
181
|
+
skeletonLine: { height: 12, borderRadius: 6 },
|
|
182
|
+
});
|