@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,70 @@
|
|
|
1
|
+
import type { TargetRect } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maps a coachmark anchor id → a function that measures that element's current
|
|
5
|
+
* window rect. Elements register themselves on mount (via useCoachmarkAnchor)
|
|
6
|
+
* and the tour controller resolves an id to a rect at the moment a step fires.
|
|
7
|
+
*
|
|
8
|
+
* This is the "app declares WHERE" half of the engine: the kit owns the
|
|
9
|
+
* measuring/registry mechanics, each app decides which ids exist and pins them
|
|
10
|
+
* to its own components.
|
|
11
|
+
*/
|
|
12
|
+
export type AnchorMeasurer = () => Promise<TargetRect | null>;
|
|
13
|
+
|
|
14
|
+
const anchors = new Map<string, AnchorMeasurer>();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* NOTE: this registry is last-writer-wins — one measurer per id. An `anchorId`
|
|
18
|
+
* MUST be unique across screens that can be mounted simultaneously; if two live
|
|
19
|
+
* screens share an id, the second registration hides the first and unmount order
|
|
20
|
+
* decides who survives. Scope ids per screen (e.g. `feed.filter_icon`) when a
|
|
21
|
+
* component can appear in more than one mounted screen at once.
|
|
22
|
+
*/
|
|
23
|
+
export const coachmarkAnchors = {
|
|
24
|
+
register(id: string, measurer: AnchorMeasurer) {
|
|
25
|
+
anchors.set(id, measurer);
|
|
26
|
+
},
|
|
27
|
+
/**
|
|
28
|
+
* Only clears the id when `measurer` is still the current registration, so a
|
|
29
|
+
* screen unmounting after a newer screen registered the same id does not wipe
|
|
30
|
+
* the live measurer. Omit `measurer` to force-delete regardless.
|
|
31
|
+
*/
|
|
32
|
+
unregister(id: string, measurer?: AnchorMeasurer) {
|
|
33
|
+
if (measurer !== undefined && anchors.get(id) !== measurer) return;
|
|
34
|
+
anchors.delete(id);
|
|
35
|
+
},
|
|
36
|
+
get(id: string): AnchorMeasurer | undefined {
|
|
37
|
+
return anchors.get(id);
|
|
38
|
+
},
|
|
39
|
+
/**
|
|
40
|
+
* Resolve an anchor to a rect, retrying briefly while the target lays out
|
|
41
|
+
* (e.g. the first feed post, or a screen just navigated to). Cancellable via
|
|
42
|
+
* `shouldCancel` so a superseded step abandons its retry loop immediately.
|
|
43
|
+
*/
|
|
44
|
+
async resolve(
|
|
45
|
+
id: string,
|
|
46
|
+
{
|
|
47
|
+
retries = 6,
|
|
48
|
+
intervalMs = 120,
|
|
49
|
+
shouldCancel,
|
|
50
|
+
}: {
|
|
51
|
+
retries?: number;
|
|
52
|
+
intervalMs?: number;
|
|
53
|
+
/** Abort the retry loop early (e.g. the step was superseded). */
|
|
54
|
+
shouldCancel?: () => boolean;
|
|
55
|
+
} = {},
|
|
56
|
+
): Promise<TargetRect | null> {
|
|
57
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
58
|
+
if (shouldCancel?.()) return null;
|
|
59
|
+
const measurer = anchors.get(id);
|
|
60
|
+
if (measurer) {
|
|
61
|
+
const rect = await measurer();
|
|
62
|
+
if (rect && rect.width > 0 && rect.height > 0) return rect;
|
|
63
|
+
}
|
|
64
|
+
if (attempt < retries) {
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
import { areCoachmarksEnabled } from "./runtime";
|
|
4
|
+
import type { GestureKind, Placement, TargetRect } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A tiny module-level store for the single active coachmark overlay. The host
|
|
8
|
+
* (CoachmarkOverlayHost, mounted once by CoachmarkProvider at the app root)
|
|
9
|
+
* subscribes and renders whatever is active; the tour controller drives it
|
|
10
|
+
* imperatively via show()/hide(). No React context is needed for the overlay
|
|
11
|
+
* itself — the host reaches the root so the ring can paint above the tab bar.
|
|
12
|
+
*
|
|
13
|
+
* Only ONE overlay is ever active (steps never stack), per the engine's
|
|
14
|
+
* one-overlay-at-a-time rule.
|
|
15
|
+
*/
|
|
16
|
+
export interface CoachmarkOverlayState {
|
|
17
|
+
message: string;
|
|
18
|
+
targetRect: TargetRect | null;
|
|
19
|
+
gesture: GestureKind;
|
|
20
|
+
placement: Placement;
|
|
21
|
+
onEngage: () => void;
|
|
22
|
+
onDismiss: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let current: CoachmarkOverlayState | null = null;
|
|
26
|
+
const listeners = new Set<() => void>();
|
|
27
|
+
|
|
28
|
+
const emit = () => {
|
|
29
|
+
for (const listener of listeners) listener();
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const coachmarkOverlay = {
|
|
33
|
+
show(state: CoachmarkOverlayState) {
|
|
34
|
+
// Feature kill switch: when coachmarks are disabled, a show() request is a silent no-op —
|
|
35
|
+
// no ring paints, and because nothing shows the user can't advance/dismiss a tour, so no
|
|
36
|
+
// once-gate is ever written and re-enabling replays it. hide() still works (see below).
|
|
37
|
+
if (!areCoachmarksEnabled()) return;
|
|
38
|
+
current = state;
|
|
39
|
+
emit();
|
|
40
|
+
},
|
|
41
|
+
hide() {
|
|
42
|
+
if (current === null) return;
|
|
43
|
+
current = null;
|
|
44
|
+
emit();
|
|
45
|
+
},
|
|
46
|
+
subscribe(listener: () => void) {
|
|
47
|
+
listeners.add(listener);
|
|
48
|
+
return () => {
|
|
49
|
+
listeners.delete(listener);
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
getSnapshot(): CoachmarkOverlayState | null {
|
|
53
|
+
return current;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Subscribe a component to the active overlay (or null). */
|
|
58
|
+
export const useCoachmarkOverlay = (): CoachmarkOverlayState | null =>
|
|
59
|
+
useSyncExternalStore(
|
|
60
|
+
coachmarkOverlay.subscribe,
|
|
61
|
+
coachmarkOverlay.getSnapshot,
|
|
62
|
+
coachmarkOverlay.getSnapshot,
|
|
63
|
+
);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wireai-onboarding/coachmarks — the performance-first guided-tour engine.
|
|
3
|
+
*
|
|
4
|
+
* Subpath entry, kept OUT of the main barrel so the core kit stays dependency-
|
|
5
|
+
* free: importing this pulls in the optional peers `react-native-reanimated` and
|
|
6
|
+
* `expo-blur`. The app declares WHERE things anchor (useCoachmarkAnchor) and
|
|
7
|
+
* WHICH tour plays (useCoachmarkTour); the kit owns the animation, measuring,
|
|
8
|
+
* blur, ring, gesture hand, and one-overlay-at-a-time queue.
|
|
9
|
+
*
|
|
10
|
+
* import {
|
|
11
|
+
* CoachmarkProvider, useCoachmarkAnchor, useCoachmarkTour,
|
|
12
|
+
* } from "wireai-onboarding/coachmarks";
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ─── Root provider (mount once, around the NavigationContainer) ───────────────
|
|
16
|
+
export { CoachmarkProvider } from "./CoachmarkProvider";
|
|
17
|
+
export type { CoachmarkProviderProps } from "./CoachmarkProvider";
|
|
18
|
+
|
|
19
|
+
// ─── Hooks (the declarative DX surface) ───────────────────────────────────────
|
|
20
|
+
export { useCoachmarkAnchor } from "./useCoachmarkAnchor";
|
|
21
|
+
export { useCoachmarkTour } from "./useCoachmarkTour";
|
|
22
|
+
export type { UseCoachmarkTourOptions } from "./useCoachmarkTour";
|
|
23
|
+
|
|
24
|
+
// ─── QA replay flag ───────────────────────────────────────────────────────────
|
|
25
|
+
export {
|
|
26
|
+
setCoachmarkTesting,
|
|
27
|
+
isCoachmarkTesting,
|
|
28
|
+
coachmarkGateKey,
|
|
29
|
+
showcaseGateKey,
|
|
30
|
+
hasSeenGate,
|
|
31
|
+
markSeenGate,
|
|
32
|
+
setCoachmarkStorage,
|
|
33
|
+
getCoachmarkStorage,
|
|
34
|
+
setCoachmarksEnabled,
|
|
35
|
+
areCoachmarksEnabled,
|
|
36
|
+
} from "./runtime";
|
|
37
|
+
|
|
38
|
+
// ─── Phase-3 (AI selection) seam ──────────────────────────────────────────────
|
|
39
|
+
export { selectTourSteps } from "./selectTourSteps";
|
|
40
|
+
|
|
41
|
+
// ─── Primitives (compose your own overlay if the tour hook isn't enough) ──────
|
|
42
|
+
export { GestureHint } from "./GestureHint";
|
|
43
|
+
export type { GestureHintProps } from "./GestureHint";
|
|
44
|
+
export { SpotlightOverlay } from "./SpotlightOverlay";
|
|
45
|
+
export type { SpotlightOverlayProps } from "./SpotlightOverlay";
|
|
46
|
+
export { CoachmarkOverlayHost } from "./CoachmarkOverlayHost";
|
|
47
|
+
export type { CoachmarkOverlayHostProps } from "./CoachmarkOverlayHost";
|
|
48
|
+
|
|
49
|
+
// ─── Imperative overlay store + anchor registry (advanced) ────────────────────
|
|
50
|
+
export { coachmarkOverlay, useCoachmarkOverlay } from "./coachmarkOverlayStore";
|
|
51
|
+
export type { CoachmarkOverlayState } from "./coachmarkOverlayStore";
|
|
52
|
+
export { coachmarkAnchors } from "./coachmarkAnchorRegistry";
|
|
53
|
+
export type { AnchorMeasurer } from "./coachmarkAnchorRegistry";
|
|
54
|
+
|
|
55
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
56
|
+
export type {
|
|
57
|
+
GestureKind,
|
|
58
|
+
Placement,
|
|
59
|
+
TargetRect,
|
|
60
|
+
CoachmarkStorage,
|
|
61
|
+
CoachmarkStep,
|
|
62
|
+
} from "./types";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level runtime for the coachmark engine: the injected sync storage, the
|
|
3
|
+
* global `isTestingCoachmark` flag, and the seen-gate helpers built on top.
|
|
4
|
+
*
|
|
5
|
+
* WHY module singletons (not just context): the tour hooks and the anchor
|
|
6
|
+
* registry are driven imperatively from anywhere in the tree (a screen deep
|
|
7
|
+
* under the navigator), while the storage + testing flag are configured once at
|
|
8
|
+
* the root by `CoachmarkProvider`. Singletons let a hook read the gate without
|
|
9
|
+
* every call site threading the storage through props. The provider is still the
|
|
10
|
+
* single writer — it calls `setCoachmarkStorage` / `setCoachmarkTesting` on mount.
|
|
11
|
+
*/
|
|
12
|
+
import type { CoachmarkStorage } from "./types";
|
|
13
|
+
|
|
14
|
+
let storageSingleton: CoachmarkStorage | null = null;
|
|
15
|
+
let testing = false;
|
|
16
|
+
// The coachmarks feature kill switch (from GET /v1/features). Default true = fail-open: with no
|
|
17
|
+
// flags fetched, coachmarks behave exactly as before. CoachmarkProvider writes it from the
|
|
18
|
+
// resolved flags. Disabled → the tour never ARMS and the overlay `show()` is a no-op, so nothing
|
|
19
|
+
// paints and — critically — no once-gate is written, so re-enabling replays the tour correctly.
|
|
20
|
+
let coachmarksEnabled = true;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Set the coachmarks master switch (from the resolved feature flags). Default true (fail-open).
|
|
24
|
+
* Written by CoachmarkProvider; read imperatively by the tour arm + the overlay store.
|
|
25
|
+
*/
|
|
26
|
+
export const setCoachmarksEnabled = (value: boolean): void => {
|
|
27
|
+
coachmarksEnabled = value;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Whether the coachmarks module is enabled. False → tours/overlays are silently skipped. */
|
|
31
|
+
export const areCoachmarksEnabled = (): boolean => coachmarksEnabled;
|
|
32
|
+
|
|
33
|
+
/** Set (or clear) the injected sync gate storage. Called by CoachmarkProvider. */
|
|
34
|
+
export const setCoachmarkStorage = (storage: CoachmarkStorage | null): void => {
|
|
35
|
+
storageSingleton = storage;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The currently-injected gate storage, or null if no provider is mounted. */
|
|
39
|
+
export const getCoachmarkStorage = (): CoachmarkStorage | null => storageSingleton;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Toggle the global QA replay flag. When true, EVERY seen-gate reads as unseen
|
|
43
|
+
* and every seen-write is suppressed, so every tour + showcase replays on each
|
|
44
|
+
* mount. The app flips ONE boolean to QA the whole coachmark surface.
|
|
45
|
+
*/
|
|
46
|
+
export const setCoachmarkTesting = (value: boolean): void => {
|
|
47
|
+
testing = value;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Whether replay-everything QA mode is on. */
|
|
51
|
+
export const isCoachmarkTesting = (): boolean => testing;
|
|
52
|
+
|
|
53
|
+
/** Gate key for a tour: `wire_coachmark_<tourId>_seen`. */
|
|
54
|
+
export const coachmarkGateKey = (tourId: string): string =>
|
|
55
|
+
`wire_coachmark_${tourId}_seen`;
|
|
56
|
+
|
|
57
|
+
/** Gate key for a feature showcase: `wire_showcase_<id>_seen`. */
|
|
58
|
+
export const showcaseGateKey = (showcaseId: string): string =>
|
|
59
|
+
`wire_showcase_${showcaseId}_seen`;
|
|
60
|
+
|
|
61
|
+
/** Marker value written for a seen gate. */
|
|
62
|
+
const SEEN_VALUE = "1";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read a seen gate. Testing mode ALWAYS reports unseen (so everything replays).
|
|
66
|
+
* A missing storage (no provider) also reports unseen — the safe default is to
|
|
67
|
+
* show the hint rather than silently swallow it. `storageOverride` /
|
|
68
|
+
* `testingOverride` let a self-contained component (e.g. FeatureShowcase) pass
|
|
69
|
+
* its own storage without relying on the provider singleton.
|
|
70
|
+
*/
|
|
71
|
+
export const hasSeenGate = (
|
|
72
|
+
key: string,
|
|
73
|
+
storageOverride?: CoachmarkStorage | null,
|
|
74
|
+
testingOverride?: boolean,
|
|
75
|
+
): boolean => {
|
|
76
|
+
if (testingOverride ?? testing) return false;
|
|
77
|
+
const storage = storageOverride ?? storageSingleton;
|
|
78
|
+
if (!storage) return false;
|
|
79
|
+
try {
|
|
80
|
+
return storage.getItem(key) === SEEN_VALUE;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Mark a gate seen. Suppressed in testing mode (so replay never persists) and a
|
|
88
|
+
* no-op without storage. Best-effort — a throwing adapter never breaks the tour.
|
|
89
|
+
*/
|
|
90
|
+
export const markSeenGate = (
|
|
91
|
+
key: string,
|
|
92
|
+
storageOverride?: CoachmarkStorage | null,
|
|
93
|
+
testingOverride?: boolean,
|
|
94
|
+
): void => {
|
|
95
|
+
if (testingOverride ?? testing) return;
|
|
96
|
+
const storage = storageOverride ?? storageSingleton;
|
|
97
|
+
if (!storage) return;
|
|
98
|
+
try {
|
|
99
|
+
storage.setItem(key, SEEN_VALUE);
|
|
100
|
+
} catch {
|
|
101
|
+
// Best-effort: a failed write just means the hint may show once more.
|
|
102
|
+
}
|
|
103
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selectTourSteps — the Phase-3 (AI intent-mapping) seam, stubbed today.
|
|
3
|
+
*
|
|
4
|
+
* The app declares a full catalog of coachmarks (its "feature map"). Near-term,
|
|
5
|
+
* the whole catalog plays in its declared order. LATER, the Wire server will
|
|
6
|
+
* emit an ordered `coachmarks: string[]` id list chosen by the user's captured
|
|
7
|
+
* intent (see the package spec §1e / §5), and the app passes it straight here as
|
|
8
|
+
* `selection`. This function is the ONE place that turns a selection into the
|
|
9
|
+
* live ordered step list — so turning AI on is a drop-in with zero app changes:
|
|
10
|
+
*
|
|
11
|
+
* // Phase 1-2 (no AI): full catalog, declared order
|
|
12
|
+
* const steps = selectTourSteps(catalog);
|
|
13
|
+
*
|
|
14
|
+
* // Phase 3 (AI on): server-chosen subset, server order
|
|
15
|
+
* const steps = selectTourSteps(catalog, plan.coachmarks);
|
|
16
|
+
*
|
|
17
|
+
* Contract:
|
|
18
|
+
* - `selection` absent → returns the catalog unchanged (identity/declared order).
|
|
19
|
+
* - `selection` present → returns only catalog entries whose `id` appears in
|
|
20
|
+
* `selection`, ordered by `selection` (unknown ids are skipped; duplicates in
|
|
21
|
+
* `selection` are ignored after the first match).
|
|
22
|
+
*
|
|
23
|
+
* NOTE: routing steps through a catalog + this selector is NOT required today.
|
|
24
|
+
* Declaring an inline, static (module-level or memoized) steps array and passing
|
|
25
|
+
* it straight to `useCoachmarkTour` is a perfectly fine consumer pattern. The
|
|
26
|
+
* catalog + selection route exists to make the app AI-selection-ready — flip to
|
|
27
|
+
* server-chosen ordering later with zero app changes — not as the baseline.
|
|
28
|
+
*/
|
|
29
|
+
export const selectTourSteps = <T extends { id: string }>(
|
|
30
|
+
catalog: T[],
|
|
31
|
+
selection?: string[],
|
|
32
|
+
): T[] => {
|
|
33
|
+
if (!selection) return catalog;
|
|
34
|
+
|
|
35
|
+
const byId = new Map(catalog.map((item) => [item.id, item]));
|
|
36
|
+
const seen = new Set<string>();
|
|
37
|
+
const ordered: T[] = [];
|
|
38
|
+
for (const id of selection) {
|
|
39
|
+
if (seen.has(id)) continue;
|
|
40
|
+
seen.add(id);
|
|
41
|
+
const item = byId.get(id);
|
|
42
|
+
if (item) ordered.push(item);
|
|
43
|
+
}
|
|
44
|
+
return ordered;
|
|
45
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the coachmark / guided-tour overlay engine.
|
|
3
|
+
*
|
|
4
|
+
* Lifted from the Myelino app engine and made kit-generic: the app supplies the
|
|
5
|
+
* anchors + catalog + copy, the kit owns the animation/measure/queue mechanics.
|
|
6
|
+
* Dependency-free on purpose so the type surface can be imported anywhere.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The animated hand/gesture shown over or beside a highlighted target. */
|
|
10
|
+
export type GestureKind =
|
|
11
|
+
| "swipe_up"
|
|
12
|
+
| "swipe_down"
|
|
13
|
+
| "swipe_left"
|
|
14
|
+
| "swipe_right"
|
|
15
|
+
| "tap"
|
|
16
|
+
| "double_tap"
|
|
17
|
+
| "pinch"
|
|
18
|
+
| "zoom"
|
|
19
|
+
| "none";
|
|
20
|
+
|
|
21
|
+
/** Which side of the highlighted target the tooltip pill sits on. */
|
|
22
|
+
export type Placement = "top" | "bottom" | "left" | "right" | "center";
|
|
23
|
+
|
|
24
|
+
/** A measured on-screen rectangle (window coordinates from measureInWindow). */
|
|
25
|
+
export interface TargetRect {
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The host-injected gate storage. SYNCHRONOUS on purpose — a coachmark's
|
|
34
|
+
* "seen?" gate must resolve during render with no async flash (otherwise a ring
|
|
35
|
+
* momentarily paints before the read resolves and says "already seen"). MMKV's
|
|
36
|
+
* `getString`/`set` fit as-is; an in-memory `Map` wrapper works for tests. This
|
|
37
|
+
* is deliberately separate from the async `WireOnboardingStorage` used for
|
|
38
|
+
* session persistence.
|
|
39
|
+
*/
|
|
40
|
+
export interface CoachmarkStorage {
|
|
41
|
+
getItem(key: string): string | null;
|
|
42
|
+
setItem(key: string, value: string): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One step in a guided tour. `anchorId` is looked up in the anchor registry at
|
|
47
|
+
* fire time; omit it for a centered, target-less hint (e.g. a scroll gesture).
|
|
48
|
+
* `id` doubles as the analytics name for the step.
|
|
49
|
+
*/
|
|
50
|
+
export interface CoachmarkStep {
|
|
51
|
+
id: string;
|
|
52
|
+
message: string;
|
|
53
|
+
gesture?: GestureKind;
|
|
54
|
+
placement?: Placement;
|
|
55
|
+
anchorId?: string;
|
|
56
|
+
/** Extra action when the step is engaged (e.g. navigate to another tab). */
|
|
57
|
+
onEngage?: () => void;
|
|
58
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import type { View } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { coachmarkAnchors } from "./coachmarkAnchorRegistry";
|
|
5
|
+
import type { TargetRect } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Makes any element ringable by a coachmark tour. Attach the returned ref to a
|
|
9
|
+
* plain RN View wrapping the target (use `collapsable={false}` so the native
|
|
10
|
+
* node survives for measurement):
|
|
11
|
+
*
|
|
12
|
+
* const filterAnchor = useCoachmarkAnchor("filter_icon");
|
|
13
|
+
* <View ref={filterAnchor} collapsable={false}>{button}</View>
|
|
14
|
+
*
|
|
15
|
+
* The app decides which ids exist and where they live; the kit measures them at
|
|
16
|
+
* step time via `measureInWindow` (never continuously). Pass `null` to keep the
|
|
17
|
+
* hook's position stable while registering nothing (e.g. a list item that is
|
|
18
|
+
* only an anchor at one index).
|
|
19
|
+
*/
|
|
20
|
+
export const useCoachmarkAnchor = (id: string | null | undefined) => {
|
|
21
|
+
const ref = useRef<View>(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!id) return undefined;
|
|
25
|
+
|
|
26
|
+
const measure = (): Promise<TargetRect | null> =>
|
|
27
|
+
new Promise((resolve) => {
|
|
28
|
+
const node = ref.current;
|
|
29
|
+
if (!node || typeof node.measureInWindow !== "function") {
|
|
30
|
+
return resolve(null);
|
|
31
|
+
}
|
|
32
|
+
node.measureInWindow((x, y, width, height) => {
|
|
33
|
+
if (
|
|
34
|
+
[x, y, width, height].some(
|
|
35
|
+
(value) => value == null || Number.isNaN(value),
|
|
36
|
+
)
|
|
37
|
+
) {
|
|
38
|
+
return resolve(null);
|
|
39
|
+
}
|
|
40
|
+
resolve({ x, y, width, height });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
coachmarkAnchors.register(id, measure);
|
|
45
|
+
return () => coachmarkAnchors.unregister(id, measure);
|
|
46
|
+
}, [id]);
|
|
47
|
+
|
|
48
|
+
return ref;
|
|
49
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { coachmarkAnchors } from "./coachmarkAnchorRegistry";
|
|
4
|
+
import { coachmarkOverlay } from "./coachmarkOverlayStore";
|
|
5
|
+
import { areCoachmarksEnabled, coachmarkGateKey, hasSeenGate, markSeenGate } from "./runtime";
|
|
6
|
+
import type { CoachmarkStep } from "./types";
|
|
7
|
+
|
|
8
|
+
export interface UseCoachmarkTourOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Gate key for the whole tour. The kit reads/writes
|
|
11
|
+
* `wire_coachmark_<tourId>_seen` through the injected CoachmarkStorage so the
|
|
12
|
+
* app never owns the "seen" bookkeeping (the global testing flag bypasses it).
|
|
13
|
+
*/
|
|
14
|
+
tourId: string;
|
|
15
|
+
/** Run at most once ever (persisted via the gate). Default true. */
|
|
16
|
+
showOnce?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Domain gate — the tour only arms while this is true (e.g. hasPosts), and it
|
|
19
|
+
* also drives PAUSE/RESUME: if this flips false mid-tour (e.g. the user tab-
|
|
20
|
+
* switches and the screen loses focus without unmounting) the overlay is
|
|
21
|
+
* hidden but the tour's position is kept, and it resumes on the CURRENT step
|
|
22
|
+
* (re-resolving its anchor) when this returns true. See the hook JSDoc.
|
|
23
|
+
*/
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
/** Let passive value land before the first hint. */
|
|
26
|
+
startDelayMs?: number;
|
|
27
|
+
/** Fires once the tour finishes or is skipped (after the gate is written). */
|
|
28
|
+
onComplete?: () => void;
|
|
29
|
+
/**
|
|
30
|
+
* Fires once when a step first becomes visible. It is NOT re-fired when the
|
|
31
|
+
* SAME step resumes after a focus-loss pause (the last-shown step id is
|
|
32
|
+
* tracked in a ref and suppressed), so a tab-switch never inflates
|
|
33
|
+
* impressions. It fires again only when the tour advances to a new step.
|
|
34
|
+
*/
|
|
35
|
+
onStepShown?: (id: string) => void;
|
|
36
|
+
onStepEngaged?: (id: string) => void;
|
|
37
|
+
onStepDismissed?: (id: string) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_START_DELAY_MS = 3000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Plays an ordered queue of coachmark steps on the current screen through the
|
|
44
|
+
* root SpotlightOverlay (mounted by CoachmarkProvider). Engaging a step (tap the
|
|
45
|
+
* ring/tooltip) advances and fires onStepEngaged. Tapping the backdrop advances
|
|
46
|
+
* to the NEXT step and fires onStepDismissed — a stray tap or a "not now" never
|
|
47
|
+
* kills the tour; only a backdrop tap on the LAST step ends it. Both terminal
|
|
48
|
+
* paths (engaging past the end, or dismissing the last step) write the seen gate
|
|
49
|
+
* once and call onComplete, so a finished tour never nags again.
|
|
50
|
+
*
|
|
51
|
+
* Gating lives IN the kit: the tour arms only when `enabled` AND (not `showOnce`
|
|
52
|
+
* OR the `tourId` gate is unseen).
|
|
53
|
+
*
|
|
54
|
+
* PAUSE/RESUME on focus loss: `enabled` is a live gate, not just an arm-once
|
|
55
|
+
* trigger. Once armed, if `enabled` goes false while the tour is in flight
|
|
56
|
+
* (started, not finished) — the classic case being a tab-switch that keeps the
|
|
57
|
+
* screen mounted — the drive effect hides the overlay so a stale rect can't
|
|
58
|
+
* float over the wrong screen, but KEEPS `activeIndex` and does NOT write the
|
|
59
|
+
* seen gate. When `enabled` returns true the effect re-runs (it's in the deps)
|
|
60
|
+
* and re-drives the SAME step, re-resolving its anchor so a moved/relaid-out
|
|
61
|
+
* target is picked up. `onStepShown` is suppressed on that resume (last-shown
|
|
62
|
+
* step id is tracked in a ref) so a pause/resume never double-counts a step.
|
|
63
|
+
*
|
|
64
|
+
* IMPORTANT: `steps` MUST be a stable (memoized) array. If a new array identity
|
|
65
|
+
* is passed on every render the drive effect re-runs and re-shows the current
|
|
66
|
+
* step (wasteful anchor re-resolves / overlay churn), and analytics can
|
|
67
|
+
* double-fire. Memoize it, or pass an inline module-level constant.
|
|
68
|
+
*
|
|
69
|
+
* Preserved engine invariants: step callbacks live in a ref (not effect deps),
|
|
70
|
+
* anchor resolution is cancellable, and finish is idempotent (only one overlay
|
|
71
|
+
* is ever active, the gate is written once).
|
|
72
|
+
*/
|
|
73
|
+
export const useCoachmarkTour = (
|
|
74
|
+
/** Ordered coachmark steps. MUST be stable/memoized — see the note above. */
|
|
75
|
+
steps: CoachmarkStep[],
|
|
76
|
+
{
|
|
77
|
+
tourId,
|
|
78
|
+
showOnce = true,
|
|
79
|
+
enabled,
|
|
80
|
+
startDelayMs = DEFAULT_START_DELAY_MS,
|
|
81
|
+
onComplete,
|
|
82
|
+
onStepShown,
|
|
83
|
+
onStepEngaged,
|
|
84
|
+
onStepDismissed,
|
|
85
|
+
}: UseCoachmarkTourOptions,
|
|
86
|
+
) => {
|
|
87
|
+
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
|
88
|
+
const startedRef = useRef(false);
|
|
89
|
+
const finishedRef = useRef(false);
|
|
90
|
+
// Id of the step onStepShown last fired for. Lets us re-drive the current
|
|
91
|
+
// step on a focus-loss RESUME without re-firing the impression callback.
|
|
92
|
+
const lastShownStepIdRef = useRef<string | null>(null);
|
|
93
|
+
|
|
94
|
+
// Keep the latest step callbacks in a ref so parent-recreated inline callbacks
|
|
95
|
+
// don't re-trigger the drive effect (which would re-show the step and
|
|
96
|
+
// double-fire analytics). The drive effect keys only on the step, not these.
|
|
97
|
+
const callbacksRef = useRef({ onStepShown, onStepEngaged, onStepDismissed });
|
|
98
|
+
callbacksRef.current = { onStepShown, onStepEngaged, onStepDismissed };
|
|
99
|
+
|
|
100
|
+
const finish = useCallback(() => {
|
|
101
|
+
if (finishedRef.current) return;
|
|
102
|
+
finishedRef.current = true;
|
|
103
|
+
if (showOnce) markSeenGate(coachmarkGateKey(tourId));
|
|
104
|
+
coachmarkOverlay.hide();
|
|
105
|
+
setActiveIndex(null);
|
|
106
|
+
onComplete?.();
|
|
107
|
+
}, [onComplete, showOnce, tourId]);
|
|
108
|
+
|
|
109
|
+
// Arm once, after the delay, when enabled and the gate is unseen.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!enabled || startedRef.current) return undefined;
|
|
112
|
+
// Feature kill switch: never arm while coachmarks are disabled, so no timer starts, no
|
|
113
|
+
// overlay shows, and the once-gate is NOT consumed — re-enabling replays the tour.
|
|
114
|
+
if (!areCoachmarksEnabled()) return undefined;
|
|
115
|
+
if (showOnce && hasSeenGate(coachmarkGateKey(tourId))) return undefined;
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
startedRef.current = true;
|
|
118
|
+
setActiveIndex(0);
|
|
119
|
+
}, startDelayMs);
|
|
120
|
+
return () => clearTimeout(timer);
|
|
121
|
+
}, [enabled, startDelayMs, showOnce, tourId]);
|
|
122
|
+
|
|
123
|
+
// Drive the active step. Keyed on `enabled` too, so a mid-tour focus loss
|
|
124
|
+
// pauses (hide, keep position) and a return to focus resumes the same step.
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (activeIndex === null) return undefined;
|
|
127
|
+
|
|
128
|
+
// PAUSE: armed but the screen lost focus (enabled flipped false while in
|
|
129
|
+
// flight). Hide the overlay so a stale rect can't float over the wrong
|
|
130
|
+
// screen, but KEEP activeIndex and DON'T write the gate. When enabled
|
|
131
|
+
// returns true this effect re-runs and re-drives the current step below.
|
|
132
|
+
if (!enabled) {
|
|
133
|
+
if (startedRef.current && !finishedRef.current) coachmarkOverlay.hide();
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (activeIndex >= steps.length) {
|
|
138
|
+
finish();
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const step = steps[activeIndex];
|
|
143
|
+
if (!step) {
|
|
144
|
+
finish();
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
let cancelled = false;
|
|
148
|
+
|
|
149
|
+
(async () => {
|
|
150
|
+
// Re-resolve the anchor every drive (incl. resume) — rects may have moved
|
|
151
|
+
// while the screen was backgrounded / relaid out.
|
|
152
|
+
const rect = step.anchorId
|
|
153
|
+
? await coachmarkAnchors.resolve(step.anchorId, {
|
|
154
|
+
shouldCancel: () => cancelled,
|
|
155
|
+
})
|
|
156
|
+
: null;
|
|
157
|
+
if (cancelled) return;
|
|
158
|
+
|
|
159
|
+
// Fire onStepShown only when the shown step actually changes — a resume
|
|
160
|
+
// of the SAME step (post focus-loss) must not double-count the impression.
|
|
161
|
+
if (lastShownStepIdRef.current !== step.id) {
|
|
162
|
+
lastShownStepIdRef.current = step.id;
|
|
163
|
+
callbacksRef.current.onStepShown?.(step.id);
|
|
164
|
+
}
|
|
165
|
+
coachmarkOverlay.show({
|
|
166
|
+
message: step.message,
|
|
167
|
+
targetRect: rect,
|
|
168
|
+
gesture: step.gesture ?? "none",
|
|
169
|
+
placement: step.placement ?? "bottom",
|
|
170
|
+
onEngage: () => {
|
|
171
|
+
callbacksRef.current.onStepEngaged?.(step.id);
|
|
172
|
+
step.onEngage?.();
|
|
173
|
+
setActiveIndex((index) => (index ?? 0) + 1);
|
|
174
|
+
},
|
|
175
|
+
onDismiss: () => {
|
|
176
|
+
callbacksRef.current.onStepDismissed?.(step.id);
|
|
177
|
+
// Backdrop tap advances to the next step; only the LAST step's
|
|
178
|
+
// backdrop tap ends the whole tour (the gate is written once, there).
|
|
179
|
+
if (activeIndex >= steps.length - 1) {
|
|
180
|
+
finish();
|
|
181
|
+
} else {
|
|
182
|
+
setActiveIndex((index) => (index ?? 0) + 1);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
})();
|
|
187
|
+
|
|
188
|
+
return () => {
|
|
189
|
+
cancelled = true;
|
|
190
|
+
};
|
|
191
|
+
}, [activeIndex, steps, finish, enabled]);
|
|
192
|
+
|
|
193
|
+
// Safety: clear the overlay if the screen unmounts mid-tour.
|
|
194
|
+
useEffect(
|
|
195
|
+
() => () => {
|
|
196
|
+
if (!finishedRef.current) coachmarkOverlay.hide();
|
|
197
|
+
},
|
|
198
|
+
[],
|
|
199
|
+
);
|
|
200
|
+
};
|