@wireai/activation 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/AGENTS.md +73 -0
  2. package/CHANGELOG.md +572 -0
  3. package/INTEGRATION_PROMPT.md +55 -0
  4. package/LICENSE +21 -0
  5. package/README.md +923 -0
  6. package/dist/CenteredModal-C3qQBHsA.d.mts +38 -0
  7. package/dist/CenteredModal-Cdgns6--.d.ts +38 -0
  8. package/dist/coachmarks/index.d.mts +352 -0
  9. package/dist/coachmarks/index.d.ts +352 -0
  10. package/dist/coachmarks/index.js +944 -0
  11. package/dist/coachmarks/index.js.map +1 -0
  12. package/dist/coachmarks/index.mjs +918 -0
  13. package/dist/coachmarks/index.mjs.map +1 -0
  14. package/dist/decision-CFvGY6nP.d.ts +228 -0
  15. package/dist/decision-Cl8OFYzu.d.mts +228 -0
  16. package/dist/index.d.mts +1390 -0
  17. package/dist/index.d.ts +1390 -0
  18. package/dist/index.js +3260 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/index.mjs +3186 -0
  21. package/dist/index.mjs.map +1 -0
  22. package/dist/questionnaire/index.d.mts +249 -0
  23. package/dist/questionnaire/index.d.ts +249 -0
  24. package/dist/questionnaire/index.js +922 -0
  25. package/dist/questionnaire/index.js.map +1 -0
  26. package/dist/questionnaire/index.mjs +906 -0
  27. package/dist/questionnaire/index.mjs.map +1 -0
  28. package/dist/reviews/index.d.mts +213 -0
  29. package/dist/reviews/index.d.ts +213 -0
  30. package/dist/reviews/index.js +1001 -0
  31. package/dist/reviews/index.js.map +1 -0
  32. package/dist/reviews/index.mjs +971 -0
  33. package/dist/reviews/index.mjs.map +1 -0
  34. package/dist/showcase/index.d.mts +119 -0
  35. package/dist/showcase/index.d.ts +119 -0
  36. package/dist/showcase/index.js +660 -0
  37. package/dist/showcase/index.js.map +1 -0
  38. package/dist/showcase/index.mjs +651 -0
  39. package/dist/showcase/index.mjs.map +1 -0
  40. package/dist/types-BKfpdZzX.d.mts +84 -0
  41. package/dist/types-BKfpdZzX.d.ts +84 -0
  42. package/dist/types-CMuOexw0.d.mts +116 -0
  43. package/dist/types-CMuOexw0.d.ts +116 -0
  44. package/dist/types-GL_hQ0TN.d.mts +46 -0
  45. package/dist/types-GL_hQ0TN.d.ts +46 -0
  46. package/llms.txt +36 -0
  47. package/metro/index.d.ts +32 -0
  48. package/metro/index.js +83 -0
  49. package/package.json +171 -0
  50. package/src/DemoOnboarding.tsx +133 -0
  51. package/src/OnboardingFlow.tsx +504 -0
  52. package/src/WireOnboarding.tsx +248 -0
  53. package/src/analytics/analyticsEvent.ts +69 -0
  54. package/src/analytics/reportClientEvent.ts +119 -0
  55. package/src/analytics/sendPreview.ts +73 -0
  56. package/src/attribution/attribution.ts +46 -0
  57. package/src/cards/ChipSelectCard.tsx +265 -0
  58. package/src/cards/InterstitialCard.tsx +299 -0
  59. package/src/cards/NumberStepperCard.tsx +121 -0
  60. package/src/cards/SelectionCard.tsx +270 -0
  61. package/src/cards/StatusCard.tsx +170 -0
  62. package/src/cards/TextInputCard.tsx +147 -0
  63. package/src/cards/index.ts +23 -0
  64. package/src/coachmarks/CoachmarkOverlayHost.tsx +44 -0
  65. package/src/coachmarks/CoachmarkProvider.tsx +108 -0
  66. package/src/coachmarks/GestureHint.tsx +331 -0
  67. package/src/coachmarks/SpotlightOverlay.tsx +254 -0
  68. package/src/coachmarks/coachmarkAnchorRegistry.ts +70 -0
  69. package/src/coachmarks/coachmarkOverlayStore.ts +63 -0
  70. package/src/coachmarks/index.ts +62 -0
  71. package/src/coachmarks/runtime.ts +103 -0
  72. package/src/coachmarks/selectTourSteps.ts +45 -0
  73. package/src/coachmarks/types.ts +58 -0
  74. package/src/coachmarks/useCoachmarkAnchor.ts +49 -0
  75. package/src/coachmarks/useCoachmarkTour.ts +200 -0
  76. package/src/components/AnimatedSparkle.tsx +101 -0
  77. package/src/components/Button.tsx +129 -0
  78. package/src/components/CardHandoff.tsx +212 -0
  79. package/src/components/CardLayout.tsx +103 -0
  80. package/src/components/CenteredModal.tsx +184 -0
  81. package/src/components/CompletionView.tsx +186 -0
  82. package/src/components/DoneBlock.tsx +37 -0
  83. package/src/components/ErrorBlock.tsx +42 -0
  84. package/src/components/Illustration.tsx +36 -0
  85. package/src/components/LoadingBlock.tsx +182 -0
  86. package/src/components/LoadingScreen.tsx +35 -0
  87. package/src/components/OnboardingScaffold.tsx +105 -0
  88. package/src/components/StepProgress.tsx +85 -0
  89. package/src/components/loaderChrome.ts +28 -0
  90. package/src/config/onboardingFlag.ts +39 -0
  91. package/src/config/wireConfigFromEnv.ts +52 -0
  92. package/src/device/deviceContext.ts +158 -0
  93. package/src/features/WireFeaturesProvider.tsx +81 -0
  94. package/src/features/cache.ts +92 -0
  95. package/src/features/defaults.ts +67 -0
  96. package/src/features/fetchWireFeatures.ts +86 -0
  97. package/src/features/index.ts +39 -0
  98. package/src/features/types.ts +61 -0
  99. package/src/features/useWireFeatures.ts +57 -0
  100. package/src/identity/userIdentity.ts +100 -0
  101. package/src/illustrations/defaultIllustrations.tsx +99 -0
  102. package/src/index.ts +159 -0
  103. package/src/motion/centeredModalMotion.ts +45 -0
  104. package/src/motion/motionSpec.ts +133 -0
  105. package/src/motion/useReducedMotion.ts +43 -0
  106. package/src/questionnaire/QuestionnaireGate.tsx +235 -0
  107. package/src/questionnaire/decision.ts +63 -0
  108. package/src/questionnaire/index.ts +54 -0
  109. package/src/questionnaire/runtime.ts +20 -0
  110. package/src/questionnaire/transport.ts +83 -0
  111. package/src/questionnaire/types.ts +182 -0
  112. package/src/questionnaire/useQuestionnaireGate.ts +159 -0
  113. package/src/reviews/ReviewGate.tsx +316 -0
  114. package/src/reviews/ReviewModal.tsx +13 -0
  115. package/src/reviews/decision.ts +120 -0
  116. package/src/reviews/equality.ts +54 -0
  117. package/src/reviews/expo-store-review.d.ts +16 -0
  118. package/src/reviews/feedbackForm.ts +41 -0
  119. package/src/reviews/index.ts +78 -0
  120. package/src/reviews/presentation.ts +29 -0
  121. package/src/reviews/runtime.ts +45 -0
  122. package/src/reviews/storeReview.ts +62 -0
  123. package/src/reviews/transport.ts +89 -0
  124. package/src/reviews/types.ts +172 -0
  125. package/src/reviews/useReviewGate.ts +137 -0
  126. package/src/session/persistedSession.ts +143 -0
  127. package/src/session-analytics/index.ts +18 -0
  128. package/src/session-analytics/reportSessionStart.ts +144 -0
  129. package/src/session-analytics/useSessionStart.ts +114 -0
  130. package/src/showcase/FeatureShowcase.tsx +233 -0
  131. package/src/showcase/index.ts +19 -0
  132. package/src/showcase/selectShowcaseSlides.ts +45 -0
  133. package/src/showcase/showcaseColors.ts +91 -0
  134. package/src/showcase/types.ts +74 -0
  135. package/src/theme/ThemeContext.tsx +38 -0
  136. package/src/theme/defaultTheme.ts +70 -0
  137. package/src/theme/themeFromBrand.ts +93 -0
  138. package/src/theme/types.ts +88 -0
  139. package/src/theme/typography.ts +46 -0
  140. package/src/types.ts +215 -0
  141. package/src/utils/deriveAnswers.ts +43 -0
  142. package/src/utils/readProgress.ts +33 -0
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Themed ChipSelectCard — drop-in replacement for the OSS SDK's ChipSelectCard.
3
+ * Identical `name`, `propsSchema`, `defaultProps`, and `onSelect` callback contract
4
+ * so the SDK's ComponentRenderer drives it transparently. Only the look is themed.
5
+ *
6
+ * Motion (design-refs/EXTRACTED-SPEC.md rows 4-6): chips tick on press (scale
7
+ * 0.94 spring), the selected accent fill crossfades in over 160ms (native-driver
8
+ * opacity overlay) with the label flipping to `onPrimary` and a check popping in.
9
+ * Deselect reverses. Reduce motion: color swap only, check shown instantly.
10
+ */
11
+ import React, { useCallback, useEffect, useRef, useState } from "react";
12
+ import { Animated, Easing, Pressable, StyleSheet, Text, View } from "react-native";
13
+ import { z } from "zod";
14
+ import type { InjectedProps, WireAIComponent } from "wireai-rn";
15
+ import { useOnboardingTheme } from "../theme/ThemeContext";
16
+ import { captionStyle } from "../theme/typography";
17
+ import { Button } from "../components/Button";
18
+ import { CardLayout } from "../components/CardLayout";
19
+ import {
20
+ CHECK_SCALE_FROM,
21
+ CHECK_SPRING,
22
+ CHIP_FILL_MS,
23
+ CHIP_PRESS_SCALE,
24
+ PRESS_SPRING,
25
+ WIRE_BEZIER,
26
+ scaledMs,
27
+ stateChangeDuration,
28
+ } from "../motion/motionSpec";
29
+ import { useReducedMotion } from "../motion/useReducedMotion";
30
+
31
+ const easeWire = Easing.bezier(...WIRE_BEZIER);
32
+
33
+ // Mirror of the SDK schema — must stay identical so the registry + validator agree.
34
+ const schema = z.object({
35
+ title: z.string().describe("Question or section header, e.g. 'Which season?'"),
36
+ chips: z
37
+ .array(z.string())
38
+ .min(1)
39
+ .describe("List of option labels shown as chips, e.g. ['Spring','Summer','Autumn','Winter']"),
40
+ multiSelect: z.boolean().optional().describe("Allow multiple chips selected at once, default true"),
41
+ submitLabel: z.string().optional().describe("Confirm button label, default: Continue"),
42
+ maxSelections: z.number().optional().describe("Maximum number of chips the user can select at once"),
43
+ });
44
+
45
+ type Props = z.infer<typeof schema> &
46
+ InjectedProps & {
47
+ onSelect?: (selected: string | string[]) => void;
48
+ /** Fires as the live selection changes (before Continue), so the flow can prefetch the
49
+ * next turn. Same value shape as onSelect. Optional — injected by OnboardingFlow. */
50
+ onSelectionChange?: (selected: string | string[]) => void;
51
+ };
52
+
53
+ type ChipItemProps = {
54
+ chip: string;
55
+ isSelected: boolean;
56
+ multiSelect: boolean;
57
+ onToggle: (chip: string) => void;
58
+ };
59
+
60
+ const _ChipItem: React.FC<ChipItemProps> = ({ chip, isSelected, multiSelect, onToggle }) => {
61
+ const t = useOnboardingTheme();
62
+ const reduced = useReducedMotion();
63
+ const handlePress = useCallback(() => onToggle(chip), [chip, onToggle]);
64
+
65
+ // Press tick: scale 1→0.94, spring, native driver. Skipped under reduce motion.
66
+ const pressScale = useRef(new Animated.Value(1)).current;
67
+ const pressTo = useCallback(
68
+ (to: number) => {
69
+ if (reduced) return;
70
+ Animated.spring(pressScale, {
71
+ toValue: to,
72
+ friction: PRESS_SPRING.friction,
73
+ tension: PRESS_SPRING.tension,
74
+ useNativeDriver: true,
75
+ }).start();
76
+ },
77
+ [pressScale, reduced],
78
+ );
79
+ const handlePressIn = useCallback(() => pressTo(CHIP_PRESS_SCALE), [pressTo]);
80
+ const handlePressOut = useCallback(() => pressTo(1), [pressTo]);
81
+
82
+ // Selected fill: full accent pill, crossfaded in over 160ms on a native-driver
83
+ // opacity overlay (EXTRACTED-SPEC.md D7). Reduce motion: instant swap.
84
+ const fillT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
85
+ useEffect(() => {
86
+ const anim = Animated.timing(fillT, {
87
+ toValue: isSelected ? 1 : 0,
88
+ duration: scaledMs(stateChangeDuration(reduced, CHIP_FILL_MS)),
89
+ easing: easeWire,
90
+ useNativeDriver: true,
91
+ });
92
+ anim.start();
93
+ return () => anim.stop();
94
+ }, [isSelected, reduced, fillT]);
95
+
96
+ // Check pop on select (the width-slide of the canvas is a layout animation the
97
+ // native driver can't express — see EXTRACTED-SPEC.md D4).
98
+ const checkT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
99
+ useEffect(() => {
100
+ if (!isSelected) {
101
+ checkT.setValue(0);
102
+ return;
103
+ }
104
+ if (reduced) {
105
+ checkT.setValue(1);
106
+ return;
107
+ }
108
+ checkT.setValue(0);
109
+ const anim = Animated.spring(checkT, {
110
+ toValue: 1,
111
+ friction: CHECK_SPRING.friction,
112
+ tension: CHECK_SPRING.tension,
113
+ useNativeDriver: true,
114
+ });
115
+ anim.start();
116
+ return () => anim.stop();
117
+ }, [isSelected, reduced, checkT]);
118
+
119
+ const checkScale = checkT.interpolate({ inputRange: [0, 1], outputRange: [CHECK_SCALE_FROM, 1] });
120
+
121
+ return (
122
+ <Pressable
123
+ onPress={handlePress}
124
+ onPressIn={handlePressIn}
125
+ onPressOut={handlePressOut}
126
+ accessibilityRole={multiSelect ? "checkbox" : "radio"}
127
+ accessibilityState={multiSelect ? { checked: isSelected } : { selected: isSelected }}
128
+ accessibilityLabel={chip}
129
+ >
130
+ <Animated.View
131
+ style={[
132
+ styles.chip,
133
+ {
134
+ borderRadius: t.radius.full,
135
+ paddingHorizontal: t.spacing.md,
136
+ paddingVertical: t.spacing.sm,
137
+ borderColor: t.colors.border,
138
+ backgroundColor: t.colors.background,
139
+ transform: [{ scale: pressScale }],
140
+ },
141
+ ]}
142
+ >
143
+ <Animated.View
144
+ pointerEvents="none"
145
+ style={[
146
+ StyleSheet.absoluteFill,
147
+ styles.fillOverlay,
148
+ {
149
+ borderRadius: t.radius.full,
150
+ borderColor: t.colors.primary,
151
+ backgroundColor: t.colors.primary,
152
+ opacity: fillT,
153
+ },
154
+ ]}
155
+ />
156
+ {isSelected ? (
157
+ <Animated.Text
158
+ style={[
159
+ styles.check,
160
+ { color: t.colors.onPrimary, opacity: checkT, transform: [{ scale: checkScale }] },
161
+ ]}
162
+ >
163
+
164
+ </Animated.Text>
165
+ ) : null}
166
+ <Text
167
+ style={[
168
+ captionStyle(t.fonts),
169
+ styles.chipLabel,
170
+ { color: isSelected ? t.colors.onPrimary : t.colors.textMuted },
171
+ ]}
172
+ >
173
+ {chip}
174
+ </Text>
175
+ </Animated.View>
176
+ </Pressable>
177
+ );
178
+ };
179
+ const ChipItem = React.memo(_ChipItem);
180
+
181
+ const _ChipSelectCard: React.FC<Props> = ({
182
+ title,
183
+ chips,
184
+ multiSelect = true,
185
+ submitLabel = "Continue",
186
+ maxSelections,
187
+ onSelect,
188
+ onSelectionChange,
189
+ }) => {
190
+ const t = useOnboardingTheme();
191
+ const [selected, setSelected] = useState<string[]>([]);
192
+ const [submitted, setSubmitted] = useState(false);
193
+
194
+ // Report the settled selection upward (before Continue) so the flow can prefetch the next
195
+ // turn. Debouncing lives in the flow; here we just emit on every change once something's picked.
196
+ useEffect(() => {
197
+ if (!submitted && selected.length) onSelectionChange?.(multiSelect ? selected : selected[0]!);
198
+ }, [selected, submitted, multiSelect, onSelectionChange]);
199
+
200
+ const toggle = useCallback(
201
+ (chip: string) => {
202
+ if (submitted) return;
203
+ if (multiSelect) {
204
+ setSelected((prev) => {
205
+ if (prev.includes(chip)) return prev.filter((c) => c !== chip);
206
+ if (maxSelections && prev.length >= maxSelections) return prev;
207
+ return [...prev, chip];
208
+ });
209
+ } else {
210
+ setSelected([chip]);
211
+ }
212
+ },
213
+ [submitted, multiSelect, maxSelections],
214
+ );
215
+
216
+ const handleSubmit = useCallback(() => {
217
+ if (!selected.length || !onSelect || submitted) return;
218
+ setSubmitted(true);
219
+ onSelect(multiSelect ? selected : selected[0]!);
220
+ }, [selected, onSelect, submitted, multiSelect]);
221
+
222
+ return (
223
+ <CardLayout
224
+ title={title}
225
+ subtitle={maxSelections ? `Select up to ${maxSelections}` : undefined}
226
+ footer={
227
+ <Button
228
+ title={submitLabel}
229
+ onPress={handleSubmit}
230
+ variant="primary"
231
+ disabled={selected.length === 0 || submitted}
232
+ />
233
+ }
234
+ >
235
+ <View style={[styles.chipsRow, { gap: t.spacing.sm + t.spacing.xs }]}>
236
+ {chips.map((chip) => (
237
+ <ChipItem
238
+ key={chip}
239
+ chip={chip}
240
+ isSelected={selected.includes(chip)}
241
+ multiSelect={multiSelect}
242
+ onToggle={toggle}
243
+ />
244
+ ))}
245
+ </View>
246
+ </CardLayout>
247
+ );
248
+ };
249
+
250
+ export const ChipSelectCard: WireAIComponent = {
251
+ name: "ChipSelectCard",
252
+ description:
253
+ "Use for compact short labels: seasons, activities, travel styles, moods, categories. Ideal for 4–12 single-word or two-word options. Use SelectionCard instead when options need descriptions.",
254
+ component: React.memo(_ChipSelectCard) as WireAIComponent["component"],
255
+ propsSchema: schema,
256
+ defaultProps: { multiSelect: true, submitLabel: "Continue" },
257
+ };
258
+
259
+ const styles = StyleSheet.create({
260
+ chipsRow: { flexDirection: "row", flexWrap: "wrap" },
261
+ chip: { borderWidth: 1.5, flexDirection: "row", alignItems: "center", gap: 6, overflow: "hidden" },
262
+ fillOverlay: { borderWidth: 1.5 },
263
+ chipLabel: { fontWeight: "600" },
264
+ check: { fontSize: 13, fontWeight: "800", lineHeight: 15 },
265
+ });
@@ -0,0 +1,299 @@
1
+ /**
2
+ * InterstitialCard — a mid-flow "value" screen (the Duolingo / Cal-AI beat): an
3
+ * illustration + a short motivational line + a single Continue button. It is NOT a
4
+ * question — it builds momentum and reinforces what the user is committing to,
5
+ * which is one of the biggest retention levers in great onboarding.
6
+ *
7
+ * Artwork resolves in order so the screen is NEVER a blank box: the host's named
8
+ * `illustration` (brand SVG/Lottie, keeping the kit dependency-free) → a
9
+ * backend-supplied `imageUrl` via RN <Image> (which self-heals to art on load
10
+ * error) → a named `illustrationFallback` (defaults to "momentum", always present
11
+ * in the kit's default registry).
12
+ *
13
+ * A minimum display window keeps the momentum beat from flashing past when the next
14
+ * card is already cached: Continue is disabled for MIN_DISPLAY_MS after mount.
15
+ *
16
+ * Motion (design-refs/EXTRACTED-SPEC.md rows 11-13): art and title fade-up in
17
+ * sequence (500ms ease-wire, 100ms stagger); the optional `items` (short lines
18
+ * referencing what the user already said) tick on one by one (90ms stagger),
19
+ * each check popping 200ms after its row — "work being done", not a slideshow.
20
+ * Reduce motion: everything rendered at its final frame at once.
21
+ *
22
+ * On Continue it sends "continue" back to the backend so the flow advances; the
23
+ * submitted-state guard makes the tap idempotent.
24
+ */
25
+ import React, { useCallback, useEffect, useRef, useState } from "react";
26
+ import { Animated, Easing, Image, StyleSheet, Text, View } from "react-native";
27
+ import { z } from "zod";
28
+ import type { InjectedProps, WireAIComponent } from "wireai-rn";
29
+ import { useOnboardingTheme } from "../theme/ThemeContext";
30
+ import { bodyStyle, headingStyle } from "../theme/typography";
31
+ import { useIllustration } from "../components/Illustration";
32
+ import { Button } from "../components/Button";
33
+ import { CardLayout } from "../components/CardLayout";
34
+ import {
35
+ CHECK_SPRING,
36
+ INTERSTITIAL_CHECK_SCALE_FROM,
37
+ INTERSTITIAL_HEAD_MS,
38
+ INTERSTITIAL_HEAD_STAGGER_MS,
39
+ INTERSTITIAL_ITEM_MS,
40
+ WIRE_BEZIER,
41
+ interstitialCheckDelay,
42
+ scaledMs,
43
+ interstitialItemDelay,
44
+ } from "../motion/motionSpec";
45
+ import { useReducedMotion } from "../motion/useReducedMotion";
46
+
47
+ const easeWire = Easing.bezier(...WIRE_BEZIER);
48
+ const RISE_PX = 10;
49
+
50
+ const schema = z.object({
51
+ title: z.string().describe("Short, warm headline for the value/momentum moment"),
52
+ body: z
53
+ .string()
54
+ .optional()
55
+ .describe("One or two supporting lines that reflect the user's answers back and build momentum"),
56
+ items: z
57
+ .array(z.string())
58
+ .max(4)
59
+ .optional()
60
+ .describe(
61
+ "2-3 very short 'work being done' lines that each reference something the user already answered (e.g. 'Sized for a small team'). Shown as ticked items appearing in sequence. Prefer these over `body` when you have concrete answers to reflect back.",
62
+ ),
63
+ illustration: z
64
+ .string()
65
+ .optional()
66
+ .describe(
67
+ "Name of an app-provided illustration to show (the app's registry decides the artwork). Prefer this over imageUrl.",
68
+ ),
69
+ imageUrl: z
70
+ .string()
71
+ .url()
72
+ .refine((v) => v.startsWith("https://"), "imageUrl must be an https URL")
73
+ .optional()
74
+ .describe(
75
+ "Fallback remote HTTPS image URL, used only when no named illustration is registered. A non-https or malformed value is rejected and degrades to the named-illustration fallback.",
76
+ ),
77
+ illustrationFallback: z
78
+ .string()
79
+ .optional()
80
+ .describe(
81
+ "Named illustration to show when neither `illustration` nor `imageUrl` resolves (or the image fails to load). Defaults to the kit's always-present 'momentum' art so the screen is never blank.",
82
+ ),
83
+ ctaLabel: z.string().optional().describe("Continue button label, default: Continue"),
84
+ });
85
+
86
+ // Minimum time the momentum screen stays interactive-locked, so it never flashes past
87
+ // when the next card is already prefetched/cached.
88
+ const MIN_DISPLAY_MS = 500;
89
+
90
+ type Props = z.infer<typeof schema> &
91
+ InjectedProps & {
92
+ onSubmit?: (value: string) => void;
93
+ onPress?: (label: string) => void;
94
+ };
95
+
96
+ /** One ticked "work" row: fades up on its stagger slot, then the check pops. */
97
+ const _WorkItem: React.FC<{ text: string; index: number; reduced: boolean }> = ({
98
+ text,
99
+ index,
100
+ reduced,
101
+ }) => {
102
+ const t = useOnboardingTheme();
103
+ const rowT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
104
+ const checkT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
105
+
106
+ useEffect(() => {
107
+ if (reduced) {
108
+ rowT.setValue(1);
109
+ checkT.setValue(1);
110
+ return;
111
+ }
112
+ const anims = [
113
+ Animated.timing(rowT, {
114
+ toValue: 1,
115
+ duration: scaledMs(INTERSTITIAL_ITEM_MS),
116
+ delay: scaledMs(interstitialItemDelay(index)),
117
+ easing: easeWire,
118
+ useNativeDriver: true,
119
+ }),
120
+ Animated.spring(checkT, {
121
+ toValue: 1,
122
+ delay: scaledMs(interstitialCheckDelay(index)),
123
+ friction: CHECK_SPRING.friction,
124
+ tension: CHECK_SPRING.tension,
125
+ useNativeDriver: true,
126
+ }),
127
+ ];
128
+ anims.forEach((a) => a.start());
129
+ return () => anims.forEach((a) => a.stop());
130
+ }, [reduced, index, rowT, checkT]);
131
+
132
+ const rise = rowT.interpolate({ inputRange: [0, 1], outputRange: [RISE_PX, 0] });
133
+ const checkScale = checkT.interpolate({
134
+ inputRange: [0, 1],
135
+ outputRange: [INTERSTITIAL_CHECK_SCALE_FROM, 1],
136
+ });
137
+
138
+ return (
139
+ <Animated.View
140
+ style={[styles.itemRow, { gap: t.spacing.sm + t.spacing.xs, opacity: rowT, transform: [{ translateY: rise }] }]}
141
+ >
142
+ <View
143
+ style={[
144
+ styles.itemBadge,
145
+ { backgroundColor: t.colors.primarySoft, borderRadius: t.radius.full },
146
+ ]}
147
+ >
148
+ <Animated.Text
149
+ style={[
150
+ styles.itemCheck,
151
+ { color: t.colors.primary, opacity: checkT, transform: [{ scale: checkScale }] },
152
+ ]}
153
+ >
154
+
155
+ </Animated.Text>
156
+ </View>
157
+ <Text style={[bodyStyle(t.fonts), styles.itemText, { color: t.colors.text }]}>{text}</Text>
158
+ </Animated.View>
159
+ );
160
+ };
161
+ const WorkItem = React.memo(_WorkItem);
162
+
163
+ const _InterstitialCard: React.FC<Props> = ({
164
+ title,
165
+ body,
166
+ items,
167
+ illustration,
168
+ imageUrl,
169
+ illustrationFallback,
170
+ ctaLabel = "Continue",
171
+ onSubmit,
172
+ onPress,
173
+ }) => {
174
+ const t = useOnboardingTheme();
175
+ const reduced = useReducedMotion();
176
+ const node = useIllustration(illustration);
177
+ const fallbackNode = useIllustration(illustrationFallback);
178
+ const [submitted, setSubmitted] = useState(false);
179
+ const [imgFailed, setImgFailed] = useState(false);
180
+ // Keep the momentum beat on screen for at least MIN_DISPLAY_MS before Continue is tappable.
181
+ const [canAdvance, setCanAdvance] = useState(false);
182
+
183
+ useEffect(() => {
184
+ const timer = setTimeout(() => setCanAdvance(true), MIN_DISPLAY_MS);
185
+ return () => clearTimeout(timer);
186
+ }, []);
187
+
188
+ // Head reveal: art then title (then body) fade-up, 500ms ease-wire, 100ms stagger.
189
+ // Reduce motion: final frame immediately (the shell handoff supplies the short fade).
190
+ const artT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
191
+ const titleT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
192
+ const bodyT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
193
+ useEffect(() => {
194
+ if (reduced) {
195
+ artT.setValue(1);
196
+ titleT.setValue(1);
197
+ bodyT.setValue(1);
198
+ return;
199
+ }
200
+ const rise = (value: Animated.Value, delay: number) =>
201
+ Animated.timing(value, {
202
+ toValue: 1,
203
+ duration: scaledMs(INTERSTITIAL_HEAD_MS),
204
+ delay,
205
+ easing: easeWire,
206
+ useNativeDriver: true,
207
+ });
208
+ const anims = [
209
+ rise(artT, 0),
210
+ rise(titleT, scaledMs(INTERSTITIAL_HEAD_STAGGER_MS)),
211
+ rise(bodyT, scaledMs(INTERSTITIAL_HEAD_STAGGER_MS * 2)),
212
+ ];
213
+ anims.forEach((a) => a.start());
214
+ return () => anims.forEach((a) => a.stop());
215
+ }, [reduced, artT, titleT, bodyT]);
216
+
217
+ const riseStyle = (value: Animated.Value) => ({
218
+ opacity: value,
219
+ transform: [{ translateY: value.interpolate({ inputRange: [0, 1], outputRange: [RISE_PX, 0] }) }],
220
+ });
221
+
222
+ const handleContinue = useCallback(() => {
223
+ if (submitted || !canAdvance) return;
224
+ setSubmitted(true);
225
+ if (onSubmit) onSubmit("continue");
226
+ else if (onPress) onPress(ctaLabel);
227
+ }, [submitted, canAdvance, onSubmit, onPress, ctaLabel]);
228
+
229
+ // Resolution order — a broken/missing image must never leave a blank box:
230
+ // named illustration → remote image (until it errors) → named fallback art.
231
+ const showImage = !node && !!imageUrl && !imgFailed;
232
+ const art = node ?? (showImage ? undefined : fallbackNode);
233
+
234
+ return (
235
+ <CardLayout
236
+ align="center"
237
+ footer={
238
+ <Button
239
+ title={ctaLabel}
240
+ onPress={handleContinue}
241
+ variant="primary"
242
+ disabled={submitted || !canAdvance}
243
+ />
244
+ }
245
+ >
246
+ <View style={[styles.center, { gap: t.spacing.md }]}>
247
+ <Animated.View style={[styles.fullWidth, riseStyle(artT)]}>
248
+ {showImage ? (
249
+ <Image
250
+ source={{ uri: imageUrl }}
251
+ style={styles.image}
252
+ resizeMode="contain"
253
+ onError={() => setImgFailed(true)}
254
+ />
255
+ ) : art ? (
256
+ <View style={styles.art}>{art}</View>
257
+ ) : null}
258
+ </Animated.View>
259
+
260
+ <Animated.View style={riseStyle(titleT)}>
261
+ <Text style={[headingStyle(t.fonts), { color: t.colors.text, textAlign: "center" }]}>{title}</Text>
262
+ </Animated.View>
263
+
264
+ {items?.length ? (
265
+ <View style={[styles.items, { gap: t.spacing.md, marginTop: t.spacing.sm }]}>
266
+ {items.map((item, i) => (
267
+ <WorkItem key={item} text={item} index={i} reduced={reduced} />
268
+ ))}
269
+ </View>
270
+ ) : body ? (
271
+ <Animated.View style={riseStyle(bodyT)}>
272
+ <Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>{body}</Text>
273
+ </Animated.View>
274
+ ) : null}
275
+ </View>
276
+ </CardLayout>
277
+ );
278
+ };
279
+
280
+ export const InterstitialCard: WireAIComponent = {
281
+ name: "InterstitialCard",
282
+ description:
283
+ "Use ONCE mid-flow (never first, never last) as a momentum/value screen between questions — an illustration + a warm line that reflects answers back, like Duolingo/Cal-AI. NOT a question; the user just taps Continue. Set `illustration` to an app-registered artwork name. Prefer `items` (2-3 short ticked lines, each reflecting a real answer) when you have the signal. Skip it if you don't have enough signal to make the line specific.",
284
+ component: React.memo(_InterstitialCard) as WireAIComponent["component"],
285
+ propsSchema: schema,
286
+ defaultProps: { ctaLabel: "Continue", illustrationFallback: "momentum" },
287
+ };
288
+
289
+ const styles = StyleSheet.create({
290
+ center: { width: "100%", alignItems: "center", justifyContent: "center" },
291
+ fullWidth: { width: "100%", alignItems: "center" },
292
+ art: { alignItems: "center", justifyContent: "center" },
293
+ image: { width: "100%", height: 200 },
294
+ items: { width: "100%", alignSelf: "stretch" },
295
+ itemRow: { flexDirection: "row", alignItems: "center" },
296
+ itemBadge: { width: 24, height: 24, alignItems: "center", justifyContent: "center" },
297
+ itemCheck: { fontSize: 13, fontWeight: "800", lineHeight: 15 },
298
+ itemText: { flex: 1 },
299
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Themed NumberStepperCard — drop-in for the OSS SDK's NumberStepperCard. Same
3
+ * `name`, `propsSchema`, `defaultProps`, and `onSubmit(value: number)` contract.
4
+ * Keeps the SDK's defensive numeric coercion so a malformed LLM payload can never
5
+ * feed NaN/Infinity into layout math.
6
+ */
7
+ import React, { useCallback, useState } from "react";
8
+ import { Pressable, StyleSheet, Text, View } from "react-native";
9
+ import { z } from "zod";
10
+ import type { InjectedProps, WireAIComponent } from "wireai-rn";
11
+ import { useOnboardingTheme } from "../theme/ThemeContext";
12
+ import { captionStyle, headingStyle } from "../theme/typography";
13
+ import { Button } from "../components/Button";
14
+ import { CardLayout } from "../components/CardLayout";
15
+
16
+ const schema = z.object({
17
+ label: z.string().describe("What is being counted, e.g. 'Number of days' or 'Travellers'"),
18
+ min: z.number().optional().describe("Minimum value, default 1"),
19
+ max: z.number().optional().describe("Maximum value, default 30"),
20
+ defaultValue: z.number().optional().describe("Starting value"),
21
+ step: z.number().optional().describe("Increment per tap, default 1"),
22
+ unit: z.string().optional().describe("Unit label shown next to value, e.g. 'days' or 'people'"),
23
+ submitLabel: z.string().optional().describe("Confirm button label, default: Confirm"),
24
+ });
25
+
26
+ type Props = z.infer<typeof schema> &
27
+ InjectedProps & {
28
+ onSubmit?: (value: number) => void;
29
+ };
30
+
31
+ const safeNum = (n: unknown, fallback: number): number =>
32
+ typeof n === "number" && Number.isFinite(n) ? n : fallback;
33
+
34
+ const _NumberStepperCard: React.FC<Props> = ({
35
+ label,
36
+ min,
37
+ max,
38
+ defaultValue,
39
+ step,
40
+ unit,
41
+ submitLabel = "Confirm",
42
+ onSubmit,
43
+ }) => {
44
+ const t = useOnboardingTheme();
45
+
46
+ const safeMin = safeNum(min, 1);
47
+ const safeMaxRaw = safeNum(max, 30);
48
+ const safeMax = safeMaxRaw < safeMin ? safeMin : safeMaxRaw;
49
+ const safeStepRaw = safeNum(step, 1);
50
+ const safeStep = safeStepRaw > 0 ? safeStepRaw : 1;
51
+ const safeDefault = safeNum(defaultValue, safeMin);
52
+
53
+ const [value, setValue] = useState(Math.min(Math.max(safeDefault, safeMin), safeMax));
54
+ const [submitted, setSubmitted] = useState(false);
55
+
56
+ const handleDecrement = useCallback(() => {
57
+ if (!submitted) setValue((v) => Math.max(v - safeStep, safeMin));
58
+ }, [submitted, safeStep, safeMin]);
59
+
60
+ const handleIncrement = useCallback(() => {
61
+ if (!submitted) setValue((v) => Math.min(v + safeStep, safeMax));
62
+ }, [submitted, safeStep, safeMax]);
63
+
64
+ const handleSubmit = useCallback(() => {
65
+ if (!submitted) {
66
+ setSubmitted(true);
67
+ onSubmit?.(value);
68
+ }
69
+ }, [submitted, onSubmit, value]);
70
+
71
+ const atMin = value <= safeMin;
72
+ const atMax = value >= safeMax;
73
+
74
+ const stepBtnStyle = (disabled: boolean) => [
75
+ styles.stepBtn,
76
+ { borderRadius: t.radius.full, backgroundColor: t.colors.background, borderColor: disabled ? t.colors.border : t.colors.primary },
77
+ ];
78
+
79
+ return (
80
+ <CardLayout
81
+ title={label}
82
+ align="center"
83
+ footer={
84
+ <Button title={submitLabel} onPress={handleSubmit} variant="primary" disabled={submitted} />
85
+ }
86
+ >
87
+ <View style={{ gap: t.spacing.md, alignItems: "center" }}>
88
+ <View style={[styles.stepper, { gap: t.spacing.lg }]}>
89
+ <Pressable onPress={atMin || submitted ? undefined : handleDecrement} disabled={atMin || submitted} style={stepBtnStyle(atMin)}>
90
+ <Text style={[headingStyle(t.fonts), { color: atMin ? t.colors.textMuted : t.colors.primary }]}>{"−"}</Text>
91
+ </Pressable>
92
+ <View style={styles.valueBox}>
93
+ <Text style={[headingStyle(t.fonts), { color: t.colors.primary }]}>{String(value)}</Text>
94
+ {unit ? <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>{unit}</Text> : null}
95
+ </View>
96
+ <Pressable onPress={atMax || submitted ? undefined : handleIncrement} disabled={atMax || submitted} style={stepBtnStyle(atMax)}>
97
+ <Text style={[headingStyle(t.fonts), { color: atMax ? t.colors.textMuted : t.colors.primary }]}>{"+"}</Text>
98
+ </Pressable>
99
+ </View>
100
+ <Text style={[captionStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
101
+ {`${safeMin} – ${safeMax}${unit ? ` ${unit}` : ""}`}
102
+ </Text>
103
+ </View>
104
+ </CardLayout>
105
+ );
106
+ };
107
+
108
+ export const NumberStepperCard: WireAIComponent = {
109
+ name: "NumberStepperCard",
110
+ description:
111
+ "Use for any bounded numeric input: days, people, portions, budget tiers, ratings. Best for ranges 1–99. Set unit (e.g. 'days', 'people') for clarity.",
112
+ component: React.memo(_NumberStepperCard) as WireAIComponent["component"],
113
+ propsSchema: schema,
114
+ defaultProps: { min: 1, max: 30, defaultValue: 1, step: 1, submitLabel: "Confirm" },
115
+ };
116
+
117
+ const styles = StyleSheet.create({
118
+ stepper: { flexDirection: "row", alignItems: "center", justifyContent: "center" },
119
+ stepBtn: { width: 44, height: 44, borderWidth: 2, alignItems: "center", justifyContent: "center" },
120
+ valueBox: { alignItems: "center", minWidth: 80 },
121
+ });