@wireai/activation 0.3.0 → 0.4.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/dist/analytics/index.js +46 -4
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +46 -4
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.js +6 -1
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +6 -1
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/index.d.mts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +220 -151
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +221 -152
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +1 -1
- package/dist/questionnaire/index.d.ts +1 -1
- package/dist/questionnaire/index.js +29 -5
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +30 -6
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +1 -1
- package/dist/reviews/index.d.ts +1 -1
- package/dist/reviews/index.js +40 -9
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +42 -11
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.d.mts +1 -14
- package/dist/showcase/index.d.ts +1 -14
- package/dist/showcase/index.js +8 -2
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +8 -2
- package/dist/showcase/index.mjs.map +1 -1
- package/package.json +3 -2
- package/src/OnboardingFlow.tsx +8 -4
- package/src/WireOnboarding.tsx +8 -3
- package/src/analytics/analyticsFacade.ts +14 -1
- package/src/analytics/eventQueue.ts +5 -0
- package/src/analytics/useAnalytics.ts +7 -1
- package/src/analytics/useScreenTracking.ts +23 -1
- package/src/cards/InterstitialCard.tsx +1 -1
- package/src/cards/NumberStepperCard.tsx +12 -7
- package/src/cards/StatusCard.tsx +13 -8
- package/src/cards/TextInputCard.tsx +10 -5
- package/src/components/AnimatedSparkle.tsx +20 -3
- package/src/components/Button.tsx +10 -1
- package/src/components/CardHandoff.tsx +2 -6
- package/src/components/CardLayout.tsx +16 -10
- package/src/components/Illustration.tsx +9 -5
- package/src/components/LoadingBlock.tsx +44 -29
- package/src/components/LoadingScreen.tsx +3 -2
- package/src/components/OnboardingScaffold.tsx +21 -23
- package/src/features/WireFeaturesProvider.tsx +7 -2
- package/src/questionnaire/QuestionnaireGate.tsx +15 -3
- package/src/reviews/ReviewGate.tsx +27 -7
- package/src/reviews/ReviewModal.tsx +4 -0
- package/src/showcase/FeatureShowcase.tsx +2 -1
- package/src/components/loaderChrome.ts +0 -28
package/src/cards/StatusCard.tsx
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Reduce motion: final frame instantly. (The terminal payoff with the burst is
|
|
10
10
|
* CompletionView; this card is the inline status treatment.)
|
|
11
11
|
*/
|
|
12
|
-
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
12
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
13
13
|
import { Animated, Easing, StyleSheet, Text, View } from "react-native";
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import type { InjectedProps, WireAIComponent } from "wireai-rn";
|
|
@@ -60,13 +60,18 @@ const _StatusCard: React.FC<Props> = ({ status, title, message, ctaLabel, onCont
|
|
|
60
60
|
const reduced = useReducedMotion();
|
|
61
61
|
const [submitted, setSubmitted] = useState(false);
|
|
62
62
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
const color = useMemo(() => {
|
|
64
|
+
switch (status) {
|
|
65
|
+
case "success":
|
|
66
|
+
return t.colors.success;
|
|
67
|
+
case "warning":
|
|
68
|
+
return t.colors.warning;
|
|
69
|
+
case "error":
|
|
70
|
+
return t.colors.error;
|
|
71
|
+
default:
|
|
72
|
+
return t.colors.info;
|
|
73
|
+
}
|
|
74
|
+
}, [status, t.colors]);
|
|
70
75
|
|
|
71
76
|
// Glyph pop + copy fade-up (final frame under reduce motion).
|
|
72
77
|
const popT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
|
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* multi-line box by default so the user has room to actually write. Short fields
|
|
10
10
|
* (a name, a username) set `multiline: false` for the compact single-line height.
|
|
11
11
|
*/
|
|
12
|
-
import React, { useCallback, useState } from "react";
|
|
12
|
+
import React, { useCallback, useMemo, useState } from "react";
|
|
13
13
|
import { StyleSheet, Text, TextInput, View, useWindowDimensions } from "react-native";
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import type { InjectedProps, WireAIComponent } from "wireai-rn";
|
|
16
16
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
17
|
+
import type { OnboardingTheme } from "../theme/types";
|
|
17
18
|
import { bodyStyle, captionStyle } from "../theme/typography";
|
|
18
19
|
import { Button } from "../components/Button";
|
|
19
20
|
import { CardLayout } from "../components/CardLayout";
|
|
@@ -66,6 +67,7 @@ const _TextInputCard: React.FC<Props> = ({
|
|
|
66
67
|
lines = 6,
|
|
67
68
|
}) => {
|
|
68
69
|
const t = useOnboardingTheme();
|
|
70
|
+
const styles = useMemo(() => makeStyles(t), [t]);
|
|
69
71
|
const { height: screenHeight } = useWindowDimensions();
|
|
70
72
|
const [value, setValue] = useState("");
|
|
71
73
|
const [submitted, setSubmitted] = useState(false);
|
|
@@ -102,7 +104,7 @@ const _TextInputCard: React.FC<Props> = ({
|
|
|
102
104
|
/>
|
|
103
105
|
}
|
|
104
106
|
>
|
|
105
|
-
<View style={
|
|
107
|
+
<View style={styles.row}>
|
|
106
108
|
<TextInput
|
|
107
109
|
value={value}
|
|
108
110
|
onChangeText={setValue}
|
|
@@ -142,6 +144,9 @@ export const TextInputCard: WireAIComponent = {
|
|
|
142
144
|
defaultProps: { submitLabel: "Submit", multiline: true, lines: 6 },
|
|
143
145
|
};
|
|
144
146
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
147
|
+
function makeStyles(t: OnboardingTheme) {
|
|
148
|
+
return StyleSheet.create({
|
|
149
|
+
row: { gap: t.spacing.sm },
|
|
150
|
+
input: { borderWidth: 1 },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -81,12 +81,24 @@ const _AnimatedSparkle: React.FC<AnimatedSparkleProps> = ({ size = 30, color, va
|
|
|
81
81
|
? { opacity: pulse }
|
|
82
82
|
: { transform: [{ rotate }] };
|
|
83
83
|
|
|
84
|
+
// The glyph lives in a fixed size×size box (alignItems/justifyContent:center) so the
|
|
85
|
+
// font's side-bearing and Android includeFontPadding can't shove the visible mark off
|
|
86
|
+
// center. The box carries the transform too, so a spinning box still rotates around its
|
|
87
|
+
// own center → the ✦ spins in place, optically centered on both axes.
|
|
84
88
|
return (
|
|
85
|
-
<Animated.View style={animatedStyle}>
|
|
89
|
+
<Animated.View style={[styles.box, { width: size, height: size }, animatedStyle]}>
|
|
86
90
|
<Text
|
|
87
91
|
accessibilityElementsHidden
|
|
88
92
|
importantForAccessibility="no"
|
|
89
|
-
style={[
|
|
93
|
+
style={[
|
|
94
|
+
styles.glyph,
|
|
95
|
+
{
|
|
96
|
+
width: size,
|
|
97
|
+
fontSize: size,
|
|
98
|
+
lineHeight: size,
|
|
99
|
+
color: color ?? t.colors.primary,
|
|
100
|
+
},
|
|
101
|
+
]}
|
|
90
102
|
>
|
|
91
103
|
✦
|
|
92
104
|
</Text>
|
|
@@ -97,5 +109,10 @@ const _AnimatedSparkle: React.FC<AnimatedSparkleProps> = ({ size = 30, color, va
|
|
|
97
109
|
export const AnimatedSparkle = React.memo(_AnimatedSparkle);
|
|
98
110
|
|
|
99
111
|
const styles = StyleSheet.create({
|
|
100
|
-
|
|
112
|
+
box: { alignItems: "center", justifyContent: "center" },
|
|
113
|
+
glyph: {
|
|
114
|
+
textAlign: "center",
|
|
115
|
+
textAlignVertical: "center",
|
|
116
|
+
includeFontPadding: false,
|
|
117
|
+
},
|
|
101
118
|
});
|
|
@@ -64,6 +64,15 @@ const _Button: React.FC<ButtonProps> = ({
|
|
|
64
64
|
},
|
|
65
65
|
[pressT, reduced],
|
|
66
66
|
);
|
|
67
|
+
const lastPress = useRef<number>(0);
|
|
68
|
+
const handlePress = useCallback(() => {
|
|
69
|
+
if (!onPress) return;
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
if (now - lastPress.current < 500) return;
|
|
72
|
+
lastPress.current = now;
|
|
73
|
+
onPress();
|
|
74
|
+
}, [onPress]);
|
|
75
|
+
|
|
67
76
|
const handlePressIn = useCallback(() => pressTo(1), [pressTo]);
|
|
68
77
|
const handlePressOut = useCallback(() => pressTo(0), [pressTo]);
|
|
69
78
|
|
|
@@ -78,7 +87,7 @@ const _Button: React.FC<ButtonProps> = ({
|
|
|
78
87
|
|
|
79
88
|
return (
|
|
80
89
|
<Pressable
|
|
81
|
-
onPress={disabled ? undefined :
|
|
90
|
+
onPress={disabled ? undefined : handlePress}
|
|
82
91
|
onPressIn={disabled ? undefined : handlePressIn}
|
|
83
92
|
onPressOut={disabled ? undefined : handlePressOut}
|
|
84
93
|
disabled={disabled}
|
|
@@ -62,8 +62,6 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
|
|
|
62
62
|
const exitT = useRef(new Animated.Value(0)).current;
|
|
63
63
|
// The previous render's child, snapshotted so it can play the exit layer.
|
|
64
64
|
const prev = useRef<Snapshot>({ key: transitionKey, node: children, variant });
|
|
65
|
-
// Variant of the child currently entering (drives the enter interpolations).
|
|
66
|
-
const [enterVariant, setEnterVariant] = useState<CardHandoffVariant>(variant);
|
|
67
65
|
const running = useRef<{ enter?: Animated.CompositeAnimation; exit?: Animated.CompositeAnimation }>({});
|
|
68
66
|
const mounted = useRef(false);
|
|
69
67
|
|
|
@@ -78,7 +76,6 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
|
|
|
78
76
|
// First mount: enter only (the design's initial reveal), nothing exits.
|
|
79
77
|
if (!mounted.current) {
|
|
80
78
|
mounted.current = true;
|
|
81
|
-
setEnterVariant(variant);
|
|
82
79
|
running.current.enter = startEnter(enterT, variant, reduced, 0);
|
|
83
80
|
return;
|
|
84
81
|
}
|
|
@@ -87,7 +84,6 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
|
|
|
87
84
|
running.current.enter?.stop();
|
|
88
85
|
running.current.exit?.stop();
|
|
89
86
|
setLeaving(last);
|
|
90
|
-
setEnterVariant(variant);
|
|
91
87
|
enterT.setValue(0);
|
|
92
88
|
exitT.setValue(0);
|
|
93
89
|
|
|
@@ -122,7 +118,7 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
|
|
|
122
118
|
|
|
123
119
|
const enterStyle = {
|
|
124
120
|
opacity: enterT,
|
|
125
|
-
transform: transformFor(
|
|
121
|
+
transform: transformFor(variant, enterT, reduced, "enter"),
|
|
126
122
|
};
|
|
127
123
|
|
|
128
124
|
return (
|
|
@@ -141,7 +137,7 @@ const _CardHandoff: React.FC<CardHandoffProps> = ({
|
|
|
141
137
|
{leaving.node}
|
|
142
138
|
</Animated.View>
|
|
143
139
|
) : null}
|
|
144
|
-
<Animated.View style={[styles.fill, enterStyle]}>{children}</Animated.View>
|
|
140
|
+
<Animated.View key={variant} style={[styles.fill, enterStyle]}>{children}</Animated.View>
|
|
145
141
|
</>
|
|
146
142
|
);
|
|
147
143
|
};
|
|
@@ -22,9 +22,10 @@
|
|
|
22
22
|
* whose window already resizes for the keyboard the computed overlap is ~0, so the
|
|
23
23
|
* padding is a no-op — safe on both iOS and Android, edge-to-edge or not.
|
|
24
24
|
*/
|
|
25
|
-
import React from "react";
|
|
25
|
+
import React, { useMemo } from "react";
|
|
26
26
|
import { KeyboardAvoidingView, ScrollView, StyleSheet, Text, View } from "react-native";
|
|
27
27
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
28
|
+
import type { OnboardingTheme } from "../theme/types";
|
|
28
29
|
import { bodyStyle, labelStyle } from "../theme/typography";
|
|
29
30
|
|
|
30
31
|
export type CardLayoutProps = {
|
|
@@ -51,12 +52,13 @@ const _CardLayout: React.FC<CardLayoutProps> = ({
|
|
|
51
52
|
titleNode,
|
|
52
53
|
}) => {
|
|
53
54
|
const t = useOnboardingTheme();
|
|
55
|
+
const styles = useMemo(() => makeStyles(t), [t]);
|
|
54
56
|
const hasHeader = !!title || !!subtitle || !!titleNode;
|
|
55
57
|
|
|
56
58
|
return (
|
|
57
59
|
<KeyboardAvoidingView style={styles.fill} behavior="padding">
|
|
58
60
|
{hasHeader ? (
|
|
59
|
-
<View style={
|
|
61
|
+
<View style={styles.header}>
|
|
60
62
|
{titleNode ??
|
|
61
63
|
(title ? (
|
|
62
64
|
<Text style={[labelStyle(t.fonts), { color: t.colors.text }]}>{title}</Text>
|
|
@@ -87,17 +89,21 @@ const _CardLayout: React.FC<CardLayoutProps> = ({
|
|
|
87
89
|
{children}
|
|
88
90
|
</ScrollView>
|
|
89
91
|
|
|
90
|
-
{footer ? <View style={
|
|
92
|
+
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
|
91
93
|
</KeyboardAvoidingView>
|
|
92
94
|
);
|
|
93
95
|
};
|
|
94
96
|
|
|
95
97
|
export const CardLayout = React.memo(_CardLayout);
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
function makeStyles(t: OnboardingTheme) {
|
|
100
|
+
return StyleSheet.create({
|
|
101
|
+
fill: { flex: 1 },
|
|
102
|
+
header: { marginBottom: t.spacing.lg },
|
|
103
|
+
footer: { paddingTop: t.spacing.lg },
|
|
104
|
+
// flexGrow lets a short content block grow to fill (pinning the footer) while a
|
|
105
|
+
// tall one scrolls past the visible area.
|
|
106
|
+
scrollContent: { flexGrow: 1 },
|
|
107
|
+
center: { justifyContent: "center" },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -10,11 +10,14 @@
|
|
|
10
10
|
* When the backend names an illustration the registry doesn't have, the
|
|
11
11
|
* InterstitialCard falls back to its `imageUrl` (a plain RN <Image>).
|
|
12
12
|
*/
|
|
13
|
-
import React, { createContext, useContext } from "react";
|
|
13
|
+
import React, { createContext, useContext, useMemo } from "react";
|
|
14
14
|
|
|
15
15
|
export type IllustrationRegistry = Record<string, React.ReactNode>;
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/** One shared empty registry so an unconfigured provider never allocates a fresh {} per render. */
|
|
18
|
+
const EMPTY_REGISTRY: IllustrationRegistry = {};
|
|
19
|
+
|
|
20
|
+
const IllustrationContext = createContext<IllustrationRegistry>(EMPTY_REGISTRY);
|
|
18
21
|
|
|
19
22
|
export type IllustrationProviderProps = {
|
|
20
23
|
registry?: IllustrationRegistry;
|
|
@@ -24,9 +27,10 @@ export type IllustrationProviderProps = {
|
|
|
24
27
|
export const IllustrationProvider: React.FC<IllustrationProviderProps> = ({
|
|
25
28
|
registry,
|
|
26
29
|
children,
|
|
27
|
-
}) =>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
}) => {
|
|
31
|
+
const value = useMemo(() => registry ?? EMPTY_REGISTRY, [registry]);
|
|
32
|
+
return <IllustrationContext.Provider value={value}>{children}</IllustrationContext.Provider>;
|
|
33
|
+
};
|
|
30
34
|
|
|
31
35
|
/** Look up an app-supplied illustration node by name. Returns undefined if absent. */
|
|
32
36
|
export const useIllustration = (name?: string): React.ReactNode | undefined => {
|
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
scaledMs,
|
|
23
23
|
} from "../motion/motionSpec";
|
|
24
24
|
import { useReducedMotion } from "../motion/useReducedMotion";
|
|
25
|
-
import { useLoaderChrome } from "./loaderChrome";
|
|
26
25
|
|
|
27
26
|
const STAGE_SIZE = 96;
|
|
28
27
|
const ORBIT_DOT = 9;
|
|
@@ -104,11 +103,12 @@ const _LoadingBlock: React.FC<LoadingBlockProps> = ({
|
|
|
104
103
|
}) => {
|
|
105
104
|
const t = useOnboardingTheme();
|
|
106
105
|
const reduced = useReducedMotion();
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
106
|
+
// The loader fills its parent (flex:1) and centers in it. In the flow that parent is the
|
|
107
|
+
// scaffold BODY (the same content region the cards render into), so the loader lands at the
|
|
108
|
+
// content-area center — visually consistent with the cards it hands off to. Standalone
|
|
109
|
+
// (LoadingScreen / a playground CardFrame) it centers in whatever bounds it. No screen-inset
|
|
110
|
+
// math and no transform: it inherits the content area it is dropped into, so it is centered
|
|
111
|
+
// in every context by construction.
|
|
112
112
|
|
|
113
113
|
// Skeleton lines are gated behind the 300ms window: a fast fetch never shows them.
|
|
114
114
|
const [showSkeleton, setShowSkeleton] = useState(false);
|
|
@@ -118,34 +118,44 @@ const _LoadingBlock: React.FC<LoadingBlockProps> = ({
|
|
|
118
118
|
}, []);
|
|
119
119
|
|
|
120
120
|
return (
|
|
121
|
-
<View
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
121
|
+
<View style={[styles.center, { padding: t.spacing.lg }]}>
|
|
122
|
+
{/*
|
|
123
|
+
* The stage is the ANCHOR: it's the only in-flow child, so `justifyContent:
|
|
124
|
+
* center` lands the icon at the true center of the flow area. Title, hint and
|
|
125
|
+
* the (300ms-gated) skeleton hang BELOW it in an absolutely-positioned column,
|
|
126
|
+
* so they never displace the icon — previously they lived in the same centered
|
|
127
|
+
* column, which re-balanced (and jumped the icon UP ~½ its height) the moment
|
|
128
|
+
* the skeleton lines mounted at 300ms.
|
|
129
|
+
*/}
|
|
128
130
|
<View style={styles.stage}>
|
|
129
131
|
<View style={[styles.ring, { borderColor: t.colors.border, borderRadius: t.radius.full }]} />
|
|
130
132
|
<OrbitDot color={t.colors.primary} reduced={reduced} />
|
|
131
133
|
<AnimatedSparkle size={40} variant="pulse" />
|
|
132
134
|
</View>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
135
|
+
<View
|
|
136
|
+
style={[
|
|
137
|
+
styles.labels,
|
|
138
|
+
{ marginTop: STAGE_SIZE / 2 + t.spacing.lg, gap: t.spacing.md, paddingHorizontal: t.spacing.lg },
|
|
139
|
+
]}
|
|
140
|
+
pointerEvents="none"
|
|
141
|
+
>
|
|
142
|
+
{title ? (
|
|
143
|
+
<Text style={[headingStyle(t.fonts), { color: t.colors.text, textAlign: "center" }]}>
|
|
144
|
+
{title}
|
|
145
|
+
</Text>
|
|
146
|
+
) : null}
|
|
147
|
+
{hint ? (
|
|
148
|
+
<Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
|
|
149
|
+
{hint}
|
|
150
|
+
</Text>
|
|
151
|
+
) : null}
|
|
152
|
+
{showSkeleton ? (
|
|
153
|
+
<View style={[styles.skeleton, { gap: t.spacing.sm, marginTop: t.spacing.sm }]}>
|
|
154
|
+
<SkeletonLine width="80%" delay={0} color={t.colors.surface} reduced={reduced} />
|
|
155
|
+
<SkeletonLine width="60%" delay={200} color={t.colors.surface} reduced={reduced} />
|
|
156
|
+
</View>
|
|
157
|
+
) : null}
|
|
158
|
+
</View>
|
|
149
159
|
</View>
|
|
150
160
|
);
|
|
151
161
|
};
|
|
@@ -154,6 +164,11 @@ export const LoadingBlock = React.memo(_LoadingBlock);
|
|
|
154
164
|
|
|
155
165
|
const styles = StyleSheet.create({
|
|
156
166
|
center: { flex: 1, alignItems: "center", justifyContent: "center" },
|
|
167
|
+
// Absolutely-positioned label column, pinned just under the centered stage: its top
|
|
168
|
+
// edge starts at the flow center (top: 50%) and the inline marginTop (STAGE_SIZE/2 +
|
|
169
|
+
// a gap) drops it below the stage. Out of flow → it never moves the stage, so the
|
|
170
|
+
// 300ms skeleton reveal grows DOWNWARD only and the icon stays centered.
|
|
171
|
+
labels: { position: "absolute", top: "50%", left: 0, right: 0, alignItems: "center" },
|
|
157
172
|
stage: {
|
|
158
173
|
width: STAGE_SIZE,
|
|
159
174
|
height: STAGE_SIZE,
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* WHY a dedicated frame: those slots used to return a bare <LoadingBlock/>, whose flex:1 only
|
|
8
8
|
* fills whatever the host happened to give it — with no safe area and, if the host didn't
|
|
9
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 (
|
|
11
|
-
*
|
|
10
|
+
* uses, so the standalone loaders center exactly like the between-turns loader (both fill their
|
|
11
|
+
* parent flex:1 region and center in it — here the SafeAreaView, in the flow the scaffold body).
|
|
12
|
+
* One consistent centered look.
|
|
12
13
|
*/
|
|
13
14
|
import React from "react";
|
|
14
15
|
import { StyleSheet } from "react-native";
|
|
@@ -15,16 +15,13 @@
|
|
|
15
15
|
* RN core SafeAreaView is deprecated and iOS-only, so the kit takes the standard
|
|
16
16
|
* Expo/RN peer instead of shipping cropped layouts on Android + notched devices.
|
|
17
17
|
*/
|
|
18
|
-
import React from "react";
|
|
18
|
+
import React, { useMemo } from "react";
|
|
19
19
|
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
|
20
20
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
21
21
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
22
|
+
import type { OnboardingTheme } from "../theme/types";
|
|
22
23
|
import { bodyStyle } from "../theme/typography";
|
|
23
24
|
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
25
|
|
|
29
26
|
export type OnboardingScaffoldProps = {
|
|
30
27
|
/** 1-based index of the current step; drives the (countless) progress bar. */
|
|
@@ -53,15 +50,9 @@ const _OnboardingScaffold: React.FC<OnboardingScaffoldProps> = ({
|
|
|
53
50
|
children,
|
|
54
51
|
}) => {
|
|
55
52
|
const t = useOnboardingTheme();
|
|
53
|
+
const styles = useMemo(() => makeStyles(t), [t]);
|
|
56
54
|
const hit = { top: 12, bottom: 12, left: 12, right: 12 };
|
|
57
55
|
|
|
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
56
|
return (
|
|
66
57
|
<SafeAreaView
|
|
67
58
|
edges={["top", "bottom"]}
|
|
@@ -72,15 +63,19 @@ const _OnboardingScaffold: React.FC<OnboardingScaffoldProps> = ({
|
|
|
72
63
|
<StepProgress step={step} complete={complete} approxScreens={approxScreens} />
|
|
73
64
|
</View>
|
|
74
65
|
{onSkip ? (
|
|
75
|
-
<TouchableOpacity onPress={onSkip} hitSlop={hit} style={
|
|
66
|
+
<TouchableOpacity onPress={onSkip} hitSlop={hit} style={styles.skip}>
|
|
76
67
|
<Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted }]}>{skipLabel}</Text>
|
|
77
68
|
</TouchableOpacity>
|
|
78
69
|
) : null}
|
|
79
70
|
</View>
|
|
80
71
|
|
|
81
|
-
{/* Full-screen body: the card (via CardLayout) fills this and pins its own CTA
|
|
82
|
-
|
|
83
|
-
|
|
72
|
+
{/* Full-screen body (flex:1): the card (via CardLayout) fills this and pins its own CTA,
|
|
73
|
+
and the between-turns LoadingBlock fills+centers in this SAME region — so the loader
|
|
74
|
+
lands at the content-area center, consistent with the cards, not the full-screen center. */}
|
|
75
|
+
<View
|
|
76
|
+
style={[styles.body, { paddingHorizontal: t.spacing.md, paddingTop: t.spacing.md }]}
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
84
79
|
</View>
|
|
85
80
|
|
|
86
81
|
{onBack ? (
|
|
@@ -96,10 +91,13 @@ const _OnboardingScaffold: React.FC<OnboardingScaffoldProps> = ({
|
|
|
96
91
|
|
|
97
92
|
export const OnboardingScaffold = React.memo(_OnboardingScaffold);
|
|
98
93
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
94
|
+
function makeStyles(t: OnboardingTheme) {
|
|
95
|
+
return StyleSheet.create({
|
|
96
|
+
flex: { flex: 1 },
|
|
97
|
+
header: { flexDirection: "row", alignItems: "center" },
|
|
98
|
+
progressWrap: { flex: 1 },
|
|
99
|
+
body: { flex: 1 },
|
|
100
|
+
footer: { flexDirection: "row", justifyContent: "flex-start" },
|
|
101
|
+
skip: { marginLeft: t.spacing.md },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -18,8 +18,13 @@ import { defaultWireFeatures } from "./defaults";
|
|
|
18
18
|
import { useWireFeatures } from "./useWireFeatures";
|
|
19
19
|
import type { WireFeatures, WireFeaturesConfig } from "./types";
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
const
|
|
21
|
+
const CONTEXT_SYMBOL = Symbol.for("wireai.features.context");
|
|
22
|
+
const globalObj = global as any;
|
|
23
|
+
|
|
24
|
+
if (!globalObj[CONTEXT_SYMBOL]) {
|
|
25
|
+
globalObj[CONTEXT_SYMBOL] = createContext<WireFeatures | null>(null);
|
|
26
|
+
}
|
|
27
|
+
const WireFeaturesContext = globalObj[CONTEXT_SYMBOL];
|
|
23
28
|
|
|
24
29
|
export interface WireFeaturesProviderProps {
|
|
25
30
|
/** Tenant creds (+ optional storage) to fetch the flags once for the whole tree. */
|
|
@@ -43,7 +43,7 @@ const mergeThemeOver = (
|
|
|
43
43
|
|
|
44
44
|
type Phase = "form" | "thanks";
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
47
47
|
questionnaire,
|
|
48
48
|
target,
|
|
49
49
|
sessionId,
|
|
@@ -81,6 +81,14 @@ export const QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
|
81
81
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
82
|
}, []);
|
|
83
83
|
|
|
84
|
+
const resolveTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
85
|
+
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
return () => {
|
|
88
|
+
if (resolveTimer.current) clearTimeout(resolveTimer.current);
|
|
89
|
+
};
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
84
92
|
const resolve = useCallback(() => {
|
|
85
93
|
onResolved?.();
|
|
86
94
|
}, [onResolved]);
|
|
@@ -100,8 +108,11 @@ export const QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
|
100
108
|
submitQuestionnaireResponse(target, id, submission);
|
|
101
109
|
onEvent?.({ name: "questionnaire_submitted", id });
|
|
102
110
|
setPhase("thanks");
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
if (resolveTimer.current) clearTimeout(resolveTimer.current);
|
|
112
|
+
resolveTimer.current = setTimeout(() => {
|
|
113
|
+
modalRef.current?.close();
|
|
114
|
+
}, 1500);
|
|
115
|
+
}, [canSend, opinion, improve, suggestions, sessionId, meta, target, id, onEvent]);
|
|
105
116
|
|
|
106
117
|
// The in-card "Not now"/"Skip" link plays the popup's exit animation (which resolves on
|
|
107
118
|
// completion, the same path a backdrop tap takes). Emit the dismissed moment first.
|
|
@@ -225,6 +236,7 @@ export const QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
|
225
236
|
);
|
|
226
237
|
};
|
|
227
238
|
|
|
239
|
+
export const QuestionnaireGate = React.memo(_QuestionnaireGate);
|
|
228
240
|
QuestionnaireGate.displayName = "QuestionnaireGate";
|
|
229
241
|
|
|
230
242
|
const styles = StyleSheet.create({
|
|
@@ -52,7 +52,7 @@ const mergeThemeOver = (
|
|
|
52
52
|
|
|
53
53
|
type Phase = "rating" | "feedback" | "thanks";
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
56
56
|
appName,
|
|
57
57
|
store,
|
|
58
58
|
target,
|
|
@@ -94,10 +94,29 @@ export const ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
94
94
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
95
95
|
}, []);
|
|
96
96
|
|
|
97
|
+
const resolveTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
98
|
+
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
return () => {
|
|
101
|
+
if (resolveTimer.current) clearTimeout(resolveTimer.current);
|
|
102
|
+
};
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
97
105
|
const resolve = useCallback(() => {
|
|
98
106
|
onResolved?.();
|
|
99
107
|
}, [onResolved]);
|
|
100
108
|
|
|
109
|
+
const resolveWithDelay = useCallback(() => {
|
|
110
|
+
if (resolveTimer.current) clearTimeout(resolveTimer.current);
|
|
111
|
+
resolveTimer.current = setTimeout(() => {
|
|
112
|
+
if (mode === "modal") {
|
|
113
|
+
modalRef.current?.close();
|
|
114
|
+
} else {
|
|
115
|
+
onResolved?.();
|
|
116
|
+
}
|
|
117
|
+
}, 1500);
|
|
118
|
+
}, [mode, onResolved]);
|
|
119
|
+
|
|
101
120
|
const pickRating = useCallback(
|
|
102
121
|
async (value: number) => {
|
|
103
122
|
setStars(value);
|
|
@@ -106,12 +125,12 @@ export const ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
106
125
|
onEvent?.({ name: "store_review_requested", id, stars: value });
|
|
107
126
|
await requestStoreReview(store);
|
|
108
127
|
setPhase("thanks");
|
|
109
|
-
|
|
128
|
+
resolveWithDelay();
|
|
110
129
|
} else {
|
|
111
130
|
setPhase("feedback");
|
|
112
131
|
}
|
|
113
132
|
},
|
|
114
|
-
[onEvent, id, store,
|
|
133
|
+
[onEvent, id, store, resolveWithDelay],
|
|
115
134
|
);
|
|
116
135
|
|
|
117
136
|
const sendFeedback = useCallback(() => {
|
|
@@ -127,13 +146,13 @@ export const ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
127
146
|
submitReview(target, body);
|
|
128
147
|
onEvent?.({ name: "review_feedback_submitted", id, stars });
|
|
129
148
|
setPhase("thanks");
|
|
130
|
-
|
|
131
|
-
}, [stars, feedback, suggestion, sessionId, meta, target, onEvent, id,
|
|
149
|
+
resolveWithDelay();
|
|
150
|
+
}, [stars, feedback, suggestion, sessionId, meta, target, onEvent, id, resolveWithDelay]);
|
|
132
151
|
|
|
133
152
|
const dismiss = useCallback(() => {
|
|
134
153
|
setPhase("thanks");
|
|
135
|
-
|
|
136
|
-
}, [
|
|
154
|
+
resolveWithDelay();
|
|
155
|
+
}, [resolveWithDelay]);
|
|
137
156
|
|
|
138
157
|
// The in-card "Not now"/"Skip" link. In modal mode it plays the popup's exit animation
|
|
139
158
|
// (which resolves on completion, the same path a backdrop tap takes); inline keeps the
|
|
@@ -303,6 +322,7 @@ export const ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
303
322
|
return <View style={[styles.card, surface]}>{content}</View>;
|
|
304
323
|
};
|
|
305
324
|
|
|
325
|
+
export const ReviewGate = React.memo(_ReviewGate);
|
|
306
326
|
ReviewGate.displayName = "ReviewGate";
|
|
307
327
|
|
|
308
328
|
const styles = StyleSheet.create({
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* questionnaire module can reuse the same shell without importing reviews/. This file keeps the
|
|
6
6
|
* reviews public API (`ReviewModal`, `ReviewModalHandle`, `ReviewModalProps`) pointing at the
|
|
7
7
|
* exact same runtime object, so the reviews canary and every existing import stay valid.
|
|
8
|
+
*
|
|
9
|
+
* The exported shell is already `React.memo`-wrapped at its source (`components/CenteredModal.tsx`
|
|
10
|
+
* wraps `_CenteredModal` in `React.memo`), so `ReviewModal` satisfies the shell-memoization rule
|
|
11
|
+
* without a second wrapper here. Re-wrapping would change the runtime identity the canary asserts.
|
|
8
12
|
*/
|
|
9
13
|
export { CenteredModal as ReviewModal, default } from "../components/CenteredModal";
|
|
10
14
|
export type {
|
|
@@ -48,7 +48,7 @@ const mergeThemeOver = (
|
|
|
48
48
|
* When the gate says "seen", it renders nothing and calls `onDone` from an
|
|
49
49
|
* effect (never during render).
|
|
50
50
|
*/
|
|
51
|
-
|
|
51
|
+
const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
|
|
52
52
|
config,
|
|
53
53
|
onDone,
|
|
54
54
|
theme: themeOverride,
|
|
@@ -211,6 +211,7 @@ export const FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
|
|
|
211
211
|
);
|
|
212
212
|
};
|
|
213
213
|
|
|
214
|
+
export const FeatureShowcase = React.memo(_FeatureShowcase);
|
|
214
215
|
FeatureShowcase.displayName = "FeatureShowcase";
|
|
215
216
|
|
|
216
217
|
const styles = StyleSheet.create({
|