@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,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime helpers for the review gate: the `wire_review_*` gate keys and small
|
|
3
|
+
* typed storage accessors. The gate reuses the SAME injected sync storage + global
|
|
4
|
+
* testing flag as the coachmark engine (see coachmarks/runtime), so a host that
|
|
5
|
+
* mounted CoachmarkProvider gets review gating for free — no second storage to wire.
|
|
6
|
+
*/
|
|
7
|
+
import type { CoachmarkStorage } from "../coachmarks/types";
|
|
8
|
+
import { getCoachmarkStorage } from "../coachmarks/runtime";
|
|
9
|
+
|
|
10
|
+
/** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
|
|
11
|
+
export const reviewSeenKey = (id: string, version?: string): string =>
|
|
12
|
+
version ? `wire_review_${id}_${version}_seen` : `wire_review_${id}_seen`;
|
|
13
|
+
|
|
14
|
+
/** Last-shown timestamp key (epoch ms), for the cooldown rule. */
|
|
15
|
+
export const reviewLastShownKey = (id: string): string => `wire_review_${id}_last`;
|
|
16
|
+
|
|
17
|
+
/** Session-count key, incremented once per gate mount, for the min-sessions rule. */
|
|
18
|
+
export const reviewSessionsKey = (id: string): string => `wire_review_${id}_sessions`;
|
|
19
|
+
|
|
20
|
+
/** Resolve the storage to use: an explicit override, else the provider-injected singleton. */
|
|
21
|
+
export const resolveStorage = (
|
|
22
|
+
override?: CoachmarkStorage | null,
|
|
23
|
+
): CoachmarkStorage | null => override ?? getCoachmarkStorage();
|
|
24
|
+
|
|
25
|
+
/** Read an integer from sync storage (0 on a missing/unparseable/throwing read). */
|
|
26
|
+
export const readInt = (storage: CoachmarkStorage | null, key: string): number => {
|
|
27
|
+
if (!storage) return 0;
|
|
28
|
+
try {
|
|
29
|
+
const raw = storage.getItem(key);
|
|
30
|
+
const n = raw == null ? 0 : parseInt(raw, 10);
|
|
31
|
+
return Number.isFinite(n) ? n : 0;
|
|
32
|
+
} catch {
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Write an integer to sync storage. Best-effort — a throwing adapter never breaks the gate. */
|
|
38
|
+
export const writeInt = (storage: CoachmarkStorage | null, key: string, value: number): void => {
|
|
39
|
+
if (!storage) return;
|
|
40
|
+
try {
|
|
41
|
+
storage.setItem(key, String(value));
|
|
42
|
+
} catch {
|
|
43
|
+
// Best-effort.
|
|
44
|
+
}
|
|
45
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storeReview.ts — the 5-star route: ask for a native in-app review, or fall back to
|
|
3
|
+
* opening the store listing.
|
|
4
|
+
*
|
|
5
|
+
* `expo-store-review` is an OPTIONAL peer, loaded lazily via a guarded dynamic import so
|
|
6
|
+
* the core kit never depends on it. When present + available, we call
|
|
7
|
+
* `StoreReview.requestReview()` (the OS decides whether to actually show the dialog — it is
|
|
8
|
+
* quota'd, ~3/year on iOS, and NEVER guaranteed). When absent/unavailable, we open the
|
|
9
|
+
* configured store URL as a graceful fallback. Both paths are best-effort and never throw.
|
|
10
|
+
*
|
|
11
|
+
* Store-policy: call this ONLY on the honest 5-star path, from a NEUTRAL prompt — never a
|
|
12
|
+
* button labeled "rate us 5 stars". See the README store-policy notes.
|
|
13
|
+
*/
|
|
14
|
+
import { Linking, Platform } from "react-native";
|
|
15
|
+
|
|
16
|
+
import type { ReviewStoreConfig } from "./types";
|
|
17
|
+
|
|
18
|
+
/** The store deep-link for the current platform, or null if none is configured. */
|
|
19
|
+
export const storeUrl = (store: ReviewStoreConfig | undefined): string | null => {
|
|
20
|
+
if (!store) return null;
|
|
21
|
+
if (Platform.OS === "ios" && store.iosAppId) {
|
|
22
|
+
return `itms-apps://itunes.apple.com/app/id${store.iosAppId}?action=write-review`;
|
|
23
|
+
}
|
|
24
|
+
if (Platform.OS === "android" && store.androidPackage) {
|
|
25
|
+
return `market://details?id=${store.androidPackage}`;
|
|
26
|
+
}
|
|
27
|
+
return store.storeUrl ?? null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Open the configured store listing. Best-effort; resolves false if there was nothing to open. */
|
|
31
|
+
export const openStoreListing = async (
|
|
32
|
+
store: ReviewStoreConfig | undefined,
|
|
33
|
+
): Promise<boolean> => {
|
|
34
|
+
const url = storeUrl(store);
|
|
35
|
+
if (!url) return false;
|
|
36
|
+
try {
|
|
37
|
+
await Linking.openURL(url);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Request the native in-app review when the optional peer is available; otherwise open the
|
|
46
|
+
* store listing. Returns the path taken so the caller can fire the right analytics.
|
|
47
|
+
*/
|
|
48
|
+
export const requestStoreReview = async (
|
|
49
|
+
store?: ReviewStoreConfig,
|
|
50
|
+
): Promise<"native" | "store_url" | "none"> => {
|
|
51
|
+
try {
|
|
52
|
+
const mod = await import("expo-store-review");
|
|
53
|
+
if (mod && (await mod.isAvailableAsync())) {
|
|
54
|
+
await mod.requestReview();
|
|
55
|
+
return "native";
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Peer not installed or the native call failed — fall through to the URL fallback.
|
|
59
|
+
}
|
|
60
|
+
const opened = await openStoreListing(store);
|
|
61
|
+
return opened ? "store_url" : "none";
|
|
62
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transport.ts — kit → Wire server requests for the review module, all fire-and-forget
|
|
3
|
+
* (analytics/reviews must never break the app). Mirrors analytics/reportClientEvent: a
|
|
4
|
+
* thin fetch wrapper, Bearer tenant key, swallow every error.
|
|
5
|
+
*
|
|
6
|
+
* • submitReview → POST {serverUrl}/v1/reviews (the 1-4 feedback body)
|
|
7
|
+
* • reportAppEvent → POST {serverUrl}/v1/events (generic app.* namespace)
|
|
8
|
+
*
|
|
9
|
+
* `reportAppEvent` is the strategic extension: it lets a host report arbitrary in-app
|
|
10
|
+
* events through the SAME transport (stored server-side as event_type='app_event',
|
|
11
|
+
* question_key=<name>), which is what the backend review-firing rules evaluate on — and
|
|
12
|
+
* it seeds the broader app-analytics stream. Keep payloads minimal + non-PII.
|
|
13
|
+
*/
|
|
14
|
+
import type { ReviewSubmission, ReviewTarget } from "./types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* POST a review (the 1-4 feedback path). Fire-and-forget: a missing target, a build error,
|
|
18
|
+
* a missing `fetch`, or a network failure is swallowed and the call returns immediately.
|
|
19
|
+
*/
|
|
20
|
+
export const submitReview = (
|
|
21
|
+
target: ReviewTarget | undefined,
|
|
22
|
+
review: ReviewSubmission,
|
|
23
|
+
): void => {
|
|
24
|
+
if (!target?.serverUrl) return;
|
|
25
|
+
try {
|
|
26
|
+
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/reviews`;
|
|
27
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
28
|
+
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
29
|
+
void fetch(url, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers,
|
|
32
|
+
body: JSON.stringify(review),
|
|
33
|
+
}).catch(() => {
|
|
34
|
+
/* best-effort, swallow */
|
|
35
|
+
});
|
|
36
|
+
} catch {
|
|
37
|
+
/* URL/JSON/missing-fetch — swallow */
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
|
|
42
|
+
export interface ReportAppEventOptions {
|
|
43
|
+
/** The onboarding/session id to correlate with, when known. */
|
|
44
|
+
sessionId?: string;
|
|
45
|
+
/** A stable, non-PII device id — the review-decision endpoint reads it for min-sessions. */
|
|
46
|
+
deviceKey?: string;
|
|
47
|
+
/** Small non-PII extras. */
|
|
48
|
+
meta?: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Report a generic in-app event through the existing events transport. Stored server-side
|
|
53
|
+
* as `event_type='app_event'`, `question_key=<name>`. Fire-and-forget. Keep `name` a short
|
|
54
|
+
* stable identifier and `meta` small + non-PII.
|
|
55
|
+
*
|
|
56
|
+
* reportAppEvent(target, "content_share", { sessionId, deviceKey });
|
|
57
|
+
*/
|
|
58
|
+
export const reportAppEvent = (
|
|
59
|
+
target: ReviewTarget | undefined,
|
|
60
|
+
name: string,
|
|
61
|
+
options: ReportAppEventOptions = {},
|
|
62
|
+
): void => {
|
|
63
|
+
if (!target?.serverUrl || !name) return;
|
|
64
|
+
try {
|
|
65
|
+
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
66
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
67
|
+
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
68
|
+
const event: Record<string, unknown> = {
|
|
69
|
+
event_type: "app_event",
|
|
70
|
+
question_key: name,
|
|
71
|
+
};
|
|
72
|
+
if (options.sessionId) event.session_id = options.sessionId;
|
|
73
|
+
// device_key rides in the non-PII user_context bucket the server sanitizes; the
|
|
74
|
+
// review-decision endpoint reads it to group a device's sessions.
|
|
75
|
+
if (options.deviceKey) event.user_context = { device_key: options.deviceKey };
|
|
76
|
+
if (options.meta && Object.keys(options.meta).length > 0) {
|
|
77
|
+
event.meta = JSON.stringify(options.meta);
|
|
78
|
+
}
|
|
79
|
+
void fetch(url, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers,
|
|
82
|
+
body: JSON.stringify({ events: [event] }),
|
|
83
|
+
}).catch(() => {
|
|
84
|
+
/* best-effort, swallow */
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
/* swallow */
|
|
88
|
+
}
|
|
89
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the in-app review module (the sentiment gate).
|
|
3
|
+
*
|
|
4
|
+
* The gate asks a neutral question ("How's your experience?"), the user taps 1-5 stars,
|
|
5
|
+
* and the module routes: 5 stars to the native store review, 1-4 stars to a feather-light
|
|
6
|
+
* feedback form whose text is POSTed to the Wire server. WHEN the gate shows is decided by
|
|
7
|
+
* local rules OR a server-provided decision (the AI seam). Dependency-free type surface.
|
|
8
|
+
*/
|
|
9
|
+
import type { CoachmarkStorage } from "../coachmarks/types";
|
|
10
|
+
import type { WireFeatures, WireFeaturesConfig } from "../features/types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The firing verdict — the SAME contract on both sides of the seam. `decideReview`
|
|
14
|
+
* turns local rules into one of these; a server `{fire, reason}` (from
|
|
15
|
+
* GET /v1/reviews/decision) is the exact same shape and OVERRIDES the local one.
|
|
16
|
+
*/
|
|
17
|
+
export interface ReviewDecision {
|
|
18
|
+
fire: boolean;
|
|
19
|
+
reason?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Where to route a rating: 5 stars → the store, 1-4 → the feedback form. */
|
|
23
|
+
export type RatingRoute = "store" | "feedback";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Configurable store targets for the 5-star fallback path (used when the native
|
|
27
|
+
* `expo-store-review` peer is absent or unavailable). At least one is recommended.
|
|
28
|
+
*/
|
|
29
|
+
export interface ReviewStoreConfig {
|
|
30
|
+
/** Apple App Store numeric app id → itms-apps review deep link. */
|
|
31
|
+
iosAppId?: string;
|
|
32
|
+
/** Android package name → market:// details link. */
|
|
33
|
+
androidPackage?: string;
|
|
34
|
+
/** An explicit fallback URL (used when the platform-specific target is absent). */
|
|
35
|
+
storeUrl?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Local firing rules — v1 of the decision, overridden by a server decision when present. */
|
|
39
|
+
export interface ReviewConfig {
|
|
40
|
+
/** Gate key. Seen/last-shown/session keys derive from it (`wire_review_<id>_*`). */
|
|
41
|
+
id: string;
|
|
42
|
+
/** Master local switch. Default true. */
|
|
43
|
+
enabled?: boolean;
|
|
44
|
+
/** Require at least this many sessions on the device before showing. Default 0. */
|
|
45
|
+
minSessions?: number;
|
|
46
|
+
/** Require at least this many tracked app events before showing. Default 0. */
|
|
47
|
+
minEvents?: number;
|
|
48
|
+
/** Suppress a re-prompt within this many days of the last shown. Default 0 (off). */
|
|
49
|
+
cooldownDays?: number;
|
|
50
|
+
/** Show at most once per app version (seen-gate keyed by `appVersion`). Default true. */
|
|
51
|
+
oncePerVersion?: boolean;
|
|
52
|
+
/** The current app version, for the once-per-version seen gate. */
|
|
53
|
+
appVersion?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Client fallback ONLY: if no server decision arrives within this many ms of mount,
|
|
56
|
+
* fall back to the local rules. Null/absent → evaluate local rules immediately.
|
|
57
|
+
*/
|
|
58
|
+
timeoutFallbackMs?: number | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The four analytics moments, mirrored on the coachmark callback convention (no PII). */
|
|
62
|
+
export type ReviewGateEventName =
|
|
63
|
+
| "review_prompt_shown"
|
|
64
|
+
| "review_rating_selected"
|
|
65
|
+
| "store_review_requested"
|
|
66
|
+
| "review_feedback_submitted";
|
|
67
|
+
|
|
68
|
+
/** One analytics moment. `stars` is present on `review_rating_selected`; never any free text. */
|
|
69
|
+
export interface ReviewGateEvent {
|
|
70
|
+
name: ReviewGateEventName;
|
|
71
|
+
/** The gate id (`config.id`). */
|
|
72
|
+
id: string;
|
|
73
|
+
/** The chosen rating, on `review_rating_selected`. */
|
|
74
|
+
stars?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The POST body sent to `POST /v1/reviews` (mirrors the server `CreateReviewRequest`). */
|
|
78
|
+
export interface ReviewSubmission {
|
|
79
|
+
stars: number;
|
|
80
|
+
/** The optional "why this rating" answer (the free-text opinion box). */
|
|
81
|
+
feedback_text?: string;
|
|
82
|
+
/** The optional "what can you suggest" answer (the smaller suggestion box). */
|
|
83
|
+
suggestion?: string;
|
|
84
|
+
contact?: string;
|
|
85
|
+
session_id?: string;
|
|
86
|
+
source?: string;
|
|
87
|
+
meta?: Record<string, unknown>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How the prompt is presented:
|
|
92
|
+
* • "modal" (default) — a centered popup: dimmed backdrop, card in the middle of the
|
|
93
|
+
* screen, spring/fade entrance. What Malik asked for after the bottom-anchored inline
|
|
94
|
+
* card rendered half-hidden.
|
|
95
|
+
* • "inline" — the bare card, positioned by wherever the host mounts it (the legacy path).
|
|
96
|
+
*/
|
|
97
|
+
export type ReviewPresentation = "modal" | "inline";
|
|
98
|
+
|
|
99
|
+
/** Where kit → server requests go (same tenant creds as the onboarding flow). */
|
|
100
|
+
export interface ReviewTarget {
|
|
101
|
+
/** Base server URL (same as `WireOnboardingConfig.serverUrl`). */
|
|
102
|
+
serverUrl: string;
|
|
103
|
+
/** Tenant API key, sent as `Authorization: Bearer`. */
|
|
104
|
+
apiKey: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Props for the `ReviewGate` sentiment-gate component. */
|
|
108
|
+
export interface ReviewGateProps {
|
|
109
|
+
/** App name, woven into the neutral prompt ("Enjoying <app>?"). */
|
|
110
|
+
appName?: string;
|
|
111
|
+
/** Store targets for the 5-star fallback path when the native peer is unavailable. */
|
|
112
|
+
store?: ReviewStoreConfig;
|
|
113
|
+
/** Where to POST a 1-4 feedback submission. Omit to skip the network (callbacks still fire). */
|
|
114
|
+
target?: ReviewTarget;
|
|
115
|
+
/** The onboarding/session id correlated with the review, when known. */
|
|
116
|
+
sessionId?: string;
|
|
117
|
+
/** Small non-PII extras stored with the review (e.g. device_key, app_version). */
|
|
118
|
+
meta?: Record<string, unknown>;
|
|
119
|
+
/** Fired at each analytics moment (host wires it to its own analytics). No PII. */
|
|
120
|
+
onEvent?: (event: ReviewGateEvent) => void;
|
|
121
|
+
/** Called once the gate is resolved (rated + routed, or dismissed). */
|
|
122
|
+
onResolved?: () => void;
|
|
123
|
+
/** Called when the gate first renders to the user (also fires `review_prompt_shown`). */
|
|
124
|
+
onShown?: () => void;
|
|
125
|
+
/** Partial theme merged over the active onboarding theme. */
|
|
126
|
+
theme?: Partial<import("../theme/types").OnboardingTheme>;
|
|
127
|
+
/** Show a Skip/Not now control. Default true (never a dead end). */
|
|
128
|
+
dismissible?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* How the prompt renders. Default "modal" — a centered popup over a dimmed backdrop with
|
|
131
|
+
* a spring/fade entrance. Pass "inline" for the legacy host-positioned bare card.
|
|
132
|
+
*/
|
|
133
|
+
presentation?: ReviewPresentation;
|
|
134
|
+
/** The gate id, forwarded onto emitted events. */
|
|
135
|
+
id?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Options for `useReviewGate` — the WHEN decision. */
|
|
139
|
+
export interface UseReviewGateOptions {
|
|
140
|
+
config: ReviewConfig;
|
|
141
|
+
/** Server-provided decision (from GET /v1/reviews/decision). OVERRIDES local rules. */
|
|
142
|
+
decision?: ReviewDecision;
|
|
143
|
+
/** Current tracked app-event count (host-owned), for the `minEvents` rule. */
|
|
144
|
+
events?: number;
|
|
145
|
+
/** Sync gate storage. Defaults to the storage injected by CoachmarkProvider. */
|
|
146
|
+
storage?: CoachmarkStorage | null;
|
|
147
|
+
/** Testing override; defaults to the global `isTestingCoachmark` flag. */
|
|
148
|
+
isTesting?: boolean;
|
|
149
|
+
/**
|
|
150
|
+
* OPTIONAL feature-flag wiring — the review MASTER switch. When `flags.review.enabled` is false,
|
|
151
|
+
* the gate NEVER fires (`visible` stays false, verdict reason `feature_disabled`), whatever the
|
|
152
|
+
* local rules or a server decision say — it composes as an extra AND on top of the decision seam.
|
|
153
|
+
* Provide pre-resolved `features`, OR a `featuresConfig` to lazily fetch once, OR mount a
|
|
154
|
+
* `WireFeaturesProvider` above (context is read automatically). Omit all three and reviews stay
|
|
155
|
+
* on (fail-open), gated only by the existing rules.
|
|
156
|
+
*/
|
|
157
|
+
features?: WireFeatures;
|
|
158
|
+
/** Lazy-fetch config for the flags, used when `features` is absent and no provider is above. */
|
|
159
|
+
featuresConfig?: WireFeaturesConfig;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** What `useReviewGate` returns. */
|
|
163
|
+
export interface ReviewGateController {
|
|
164
|
+
/** Whether the gate should be shown right now. */
|
|
165
|
+
visible: boolean;
|
|
166
|
+
/** The resolved verdict (local rules or the server override). For debugging/analytics. */
|
|
167
|
+
decision: ReviewDecision;
|
|
168
|
+
/** Persist the last-shown timestamp. Call when the gate becomes visible. */
|
|
169
|
+
markShown: () => void;
|
|
170
|
+
/** Write the once-per-version seen gate. Call when the gate is resolved. */
|
|
171
|
+
markResolved: () => void;
|
|
172
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useReviewGate — owns WHEN the review gate shows.
|
|
3
|
+
*
|
|
4
|
+
* v1 decision = local rules from `config` (enabled / minSessions / minEvents / cooldownDays
|
|
5
|
+
* / oncePerVersion), evaluated against the same injected sync storage as the coachmark
|
|
6
|
+
* engine (`wire_review_*` keys). The AI seam: pass a server-provided `decision` and it
|
|
7
|
+
* OVERRIDES the local rules (see decideReview). The whole cost of being AI-ready is passing
|
|
8
|
+
* that object straight through.
|
|
9
|
+
*
|
|
10
|
+
* Fallback chain (matches the server design): (1) a server `decision` wins immediately;
|
|
11
|
+
* (2) no decision → after `timeoutFallbackMs` the local rules take over; (3) the once-gate
|
|
12
|
+
* (`wire_review_<id>_seen`) always applies locally, so a server bug can never spam the prompt.
|
|
13
|
+
* The global `isTestingCoachmark` flag force-shows the gate for QA replay.
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
16
|
+
|
|
17
|
+
import { hasSeenGate, isCoachmarkTesting, markSeenGate } from "../coachmarks/runtime";
|
|
18
|
+
import { useResolvedFeatures } from "../features/WireFeaturesProvider";
|
|
19
|
+
import { decideReview, evaluateGate, resolveRules } from "./decision";
|
|
20
|
+
import { sameDecision, shallowEqual } from "./equality";
|
|
21
|
+
import {
|
|
22
|
+
readInt,
|
|
23
|
+
resolveStorage,
|
|
24
|
+
reviewLastShownKey,
|
|
25
|
+
reviewSeenKey,
|
|
26
|
+
reviewSessionsKey,
|
|
27
|
+
writeInt,
|
|
28
|
+
} from "./runtime";
|
|
29
|
+
import type {
|
|
30
|
+
ReviewDecision,
|
|
31
|
+
ReviewGateController,
|
|
32
|
+
UseReviewGateOptions,
|
|
33
|
+
} from "./types";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Hold a stable reference for a value while its CONTENT is unchanged. Integrators
|
|
37
|
+
* pass inline `config` / `decision` objects (idiomatic React) whose identity flips
|
|
38
|
+
* every render; feeding those straight into the deps below would recompute the
|
|
39
|
+
* verdict — and mint a fresh verdict object — on every render, which loops any host
|
|
40
|
+
* that lists the returned verdict in its own effect. Comparing by value here keeps
|
|
41
|
+
* the reference (and everything derived from it) stable across identity-only churn.
|
|
42
|
+
*/
|
|
43
|
+
const useStableValue = <T,>(value: T, isEqual: (a: T, b: T) => boolean): T => {
|
|
44
|
+
const ref = useRef(value);
|
|
45
|
+
if (!isEqual(ref.current, value)) ref.current = value;
|
|
46
|
+
return ref.current;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const useReviewGate = ({
|
|
50
|
+
config: configProp,
|
|
51
|
+
decision: decisionProp,
|
|
52
|
+
events = 0,
|
|
53
|
+
storage,
|
|
54
|
+
isTesting,
|
|
55
|
+
features,
|
|
56
|
+
featuresConfig,
|
|
57
|
+
}: UseReviewGateOptions): ReviewGateController => {
|
|
58
|
+
// Tolerate inline-identity props: only adopt a new reference on a real value change.
|
|
59
|
+
const config = useStableValue(configProp, shallowEqual);
|
|
60
|
+
const decision = useStableValue(
|
|
61
|
+
decisionProp,
|
|
62
|
+
(a, b) => sameDecision(a, b),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// The review MASTER switch. Disabled → the gate never fires, composing as an extra AND over
|
|
66
|
+
// the local rules + the server decision seam below (fail-open: defaults to enabled).
|
|
67
|
+
const featureEnabled = useResolvedFeatures({ flags: features, config: featuresConfig }).review.enabled;
|
|
68
|
+
|
|
69
|
+
const testing = isTesting ?? isCoachmarkTesting();
|
|
70
|
+
const seenKey = reviewSeenKey(config.id, config.oncePerVersion === false ? undefined : config.appVersion);
|
|
71
|
+
const lastKey = reviewLastShownKey(config.id);
|
|
72
|
+
const sessionsKey = reviewSessionsKey(config.id);
|
|
73
|
+
|
|
74
|
+
// Read (and bump) the session counter ONCE per mount: this mount is a new session.
|
|
75
|
+
const sessions = useState(() => {
|
|
76
|
+
const store = resolveStorage(storage);
|
|
77
|
+
const next = readInt(store, sessionsKey) + 1;
|
|
78
|
+
writeInt(store, sessionsKey, next);
|
|
79
|
+
return next;
|
|
80
|
+
})[0];
|
|
81
|
+
|
|
82
|
+
// Gate the local rules behind an optional client-side timeout, so a reachable server
|
|
83
|
+
// gets a window to answer first. A present `decision` bypasses the wait entirely.
|
|
84
|
+
const hasServerDecision = decision != null;
|
|
85
|
+
const timeoutMs = config.timeoutFallbackMs;
|
|
86
|
+
const waits = !hasServerDecision && timeoutMs != null && timeoutMs > 0;
|
|
87
|
+
const [elapsed, setElapsed] = useState(!waits);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!waits) {
|
|
90
|
+
setElapsed(true);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const timer = setTimeout(() => setElapsed(true), timeoutMs as number);
|
|
94
|
+
return () => clearTimeout(timer);
|
|
95
|
+
}, [waits, timeoutMs]);
|
|
96
|
+
|
|
97
|
+
const { visible, verdict } = useMemo(() => {
|
|
98
|
+
// Master kill switch wins over everything, including QA replay: a tenant that turned reviews
|
|
99
|
+
// OFF must never see the gate, whatever the rules, the server decision, or testing say.
|
|
100
|
+
if (!featureEnabled) return { visible: false, verdict: { fire: false, reason: "feature_disabled" } as ReviewDecision };
|
|
101
|
+
|
|
102
|
+
// QA replay: force-show, bypassing rules + gates (safe: only via __DEV__ && bool).
|
|
103
|
+
if (testing) return { visible: true, verdict: { fire: true, reason: "testing" } as ReviewDecision };
|
|
104
|
+
|
|
105
|
+
const store = resolveStorage(storage);
|
|
106
|
+
const local = evaluateGate(resolveRules(config), {
|
|
107
|
+
sessions,
|
|
108
|
+
events,
|
|
109
|
+
lastShownAt: readInt(store, lastKey) || null,
|
|
110
|
+
seen: hasSeenGate(seenKey, storage, isTesting),
|
|
111
|
+
now: Date.now(),
|
|
112
|
+
});
|
|
113
|
+
const resolved = decideReview(local, decision);
|
|
114
|
+
// (3) the once-gate always wins locally, even over a server "fire".
|
|
115
|
+
const alreadySeen = hasSeenGate(seenKey, storage, isTesting);
|
|
116
|
+
const ready = hasServerDecision || elapsed;
|
|
117
|
+
return { visible: ready && resolved.fire && !alreadySeen, verdict: resolved };
|
|
118
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
119
|
+
}, [featureEnabled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
|
|
120
|
+
|
|
121
|
+
// Stable callbacks + a stable controller object, so a host can safely list any of
|
|
122
|
+
// them in its own effect deps without the effect re-firing every render.
|
|
123
|
+
const shownRef = useRef(false);
|
|
124
|
+
const markShown = useCallback(() => {
|
|
125
|
+
if (shownRef.current) return;
|
|
126
|
+
shownRef.current = true;
|
|
127
|
+
writeInt(resolveStorage(storage), lastKey, Date.now());
|
|
128
|
+
}, [storage, lastKey]);
|
|
129
|
+
const markResolved = useCallback(() => {
|
|
130
|
+
markSeenGate(seenKey, storage, isTesting);
|
|
131
|
+
}, [seenKey, storage, isTesting]);
|
|
132
|
+
|
|
133
|
+
return useMemo<ReviewGateController>(
|
|
134
|
+
() => ({ visible, decision: verdict, markShown, markResolved }),
|
|
135
|
+
[visible, verdict, markShown, markResolved],
|
|
136
|
+
);
|
|
137
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-id persistence — the kit caching its OWN correlation seed across app kills.
|
|
3
|
+
*
|
|
4
|
+
* WHY: the session id is minted per mount and forwarded to the backend as the A2A
|
|
5
|
+
* `contextId` (see WireOnboarding). Without persistence, an app KILL mid-onboarding
|
|
6
|
+
* mints a NEW id on relaunch — the server counts a second `session_started` and the
|
|
7
|
+
* orphaned first session reads as a phantom "drop", inflating the funnel's `started`
|
|
8
|
+
* denominator. Persisting `{ id, ts }` and reusing a still-fresh id keeps a
|
|
9
|
+
* kill-and-relaunch a SINGLE session in analytics.
|
|
10
|
+
*
|
|
11
|
+
* SCOPE: this persists the correlation seed ONLY — never answers. Answer persistence
|
|
12
|
+
* stays a host concern via `onComplete` (the kit/host boundary is unchanged).
|
|
13
|
+
*
|
|
14
|
+
* The host injects the storage via the `storage` prop (an AsyncStorage-compatible
|
|
15
|
+
* subset), keeping the kit dependency-free: an MMKV wrapper and AsyncStorage itself
|
|
16
|
+
* both fit. Every operation is best-effort — a broken or hung storage must NEVER
|
|
17
|
+
* break (or indefinitely gate) onboarding: reads race a short timeout, writes and
|
|
18
|
+
* removes swallow all errors.
|
|
19
|
+
*/
|
|
20
|
+
import { makeSessionId } from "../analytics/reportClientEvent";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The storage the host injects. Deliberately the AsyncStorage-compatible subset so
|
|
24
|
+
* `@react-native-async-storage/async-storage` can be passed as-is, and an MMKV (or
|
|
25
|
+
* any other) wrapper is three one-line methods.
|
|
26
|
+
*/
|
|
27
|
+
export type WireOnboardingStorage = {
|
|
28
|
+
getItem(key: string): Promise<string | null>;
|
|
29
|
+
setItem(key: string, value: string): Promise<void>;
|
|
30
|
+
removeItem(key: string): Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Matches the backend's session TTL (SESSION_TTL_SECONDS = 3600): an id older than
|
|
35
|
+
* this can't be resumed server-side anyway, so the client mints fresh past it.
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_SESSION_TTL_MS = 3_600_000;
|
|
38
|
+
|
|
39
|
+
/** Ceiling on the storage read — a hung adapter degrades to a fresh mint, never a stuck gate. */
|
|
40
|
+
const READ_TIMEOUT_MS = 1_500;
|
|
41
|
+
|
|
42
|
+
/** Storage key for an app's cached session, e.g. `wireai:session:myelino`. */
|
|
43
|
+
export const sessionStorageKey = (appId: string): string => `wireai:session:${appId}`;
|
|
44
|
+
|
|
45
|
+
type PersistedSession = { id: string; ts: number };
|
|
46
|
+
|
|
47
|
+
/** Resolved session for a mount: the id to use + whether it was restored from storage. */
|
|
48
|
+
export type LoadedSession = {
|
|
49
|
+
id: string;
|
|
50
|
+
/** True when a stored, still-fresh id was reused — the flow resumes instead of starting. */
|
|
51
|
+
resumed: boolean;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | undefined> => {
|
|
55
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
56
|
+
const timeout = new Promise<undefined>((resolve) => {
|
|
57
|
+
timer = setTimeout(() => resolve(undefined), ms);
|
|
58
|
+
});
|
|
59
|
+
// Clear the timer once the race settles, so a won read doesn't leave a ≤ms no-op
|
|
60
|
+
// timer holding the closure alive.
|
|
61
|
+
return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const parsePersisted = (raw: string | null | undefined): PersistedSession | undefined => {
|
|
65
|
+
if (!raw) return undefined;
|
|
66
|
+
try {
|
|
67
|
+
const parsed: unknown = JSON.parse(raw);
|
|
68
|
+
if (
|
|
69
|
+
typeof parsed === "object" &&
|
|
70
|
+
parsed !== null &&
|
|
71
|
+
typeof (parsed as PersistedSession).id === "string" &&
|
|
72
|
+
typeof (parsed as PersistedSession).ts === "number"
|
|
73
|
+
) {
|
|
74
|
+
return parsed as PersistedSession;
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Corrupt entry → treat as absent; the caller mints fresh and overwrites it.
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the session id for this mount: reuse the stored one while it's fresh
|
|
84
|
+
* (→ `resumed: true`), else mint via `makeSessionId`, persist it, and start clean.
|
|
85
|
+
* Never throws; never hangs past the read timeout.
|
|
86
|
+
*/
|
|
87
|
+
export const loadPersistedSession = async (
|
|
88
|
+
storage: WireOnboardingStorage,
|
|
89
|
+
key: string,
|
|
90
|
+
ttlMs: number = DEFAULT_SESSION_TTL_MS,
|
|
91
|
+
): Promise<LoadedSession> => {
|
|
92
|
+
let stored: PersistedSession | undefined;
|
|
93
|
+
try {
|
|
94
|
+
stored = parsePersisted(await withTimeout(storage.getItem(key), READ_TIMEOUT_MS));
|
|
95
|
+
} catch {
|
|
96
|
+
stored = undefined;
|
|
97
|
+
}
|
|
98
|
+
if (stored && Date.now() - stored.ts < ttlMs) {
|
|
99
|
+
return { id: stored.id, resumed: true };
|
|
100
|
+
}
|
|
101
|
+
const id = makeSessionId();
|
|
102
|
+
savePersistedSession(storage, key, id);
|
|
103
|
+
return { id, resumed: false };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read the persisted session WITHOUT minting a fresh one (unlike `loadPersistedSession`).
|
|
108
|
+
* Returns the stored `{ id, ts }` or `undefined` when absent/corrupt/timed-out. Used by the
|
|
109
|
+
* post-onboarding `identifyOnboarding` helper to recover the contextId to bind a user to.
|
|
110
|
+
* Never throws; never hangs past the read timeout.
|
|
111
|
+
*/
|
|
112
|
+
export const peekPersistedSession = async (
|
|
113
|
+
storage: WireOnboardingStorage,
|
|
114
|
+
key: string,
|
|
115
|
+
): Promise<{ id: string; ts: number } | undefined> => {
|
|
116
|
+
try {
|
|
117
|
+
return parsePersisted(await withTimeout(storage.getItem(key), READ_TIMEOUT_MS));
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Persist `{ id, ts }` under `key` — fire-and-forget, all errors swallowed. */
|
|
124
|
+
export const savePersistedSession = (
|
|
125
|
+
storage: WireOnboardingStorage,
|
|
126
|
+
key: string,
|
|
127
|
+
id: string,
|
|
128
|
+
): void => {
|
|
129
|
+
try {
|
|
130
|
+
void storage.setItem(key, JSON.stringify({ id, ts: Date.now() })).catch(() => {});
|
|
131
|
+
} catch {
|
|
132
|
+
// Best-effort: a failed write just means the next launch starts a fresh session.
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Remove the cached id (fired on completion) — fire-and-forget, all errors swallowed. */
|
|
137
|
+
export const clearPersistedSession = (storage: WireOnboardingStorage, key: string): void => {
|
|
138
|
+
try {
|
|
139
|
+
void storage.removeItem(key).catch(() => {});
|
|
140
|
+
} catch {
|
|
141
|
+
// Best-effort.
|
|
142
|
+
}
|
|
143
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-analytics barrel — session mapping for "the user opened the app again".
|
|
3
|
+
*
|
|
4
|
+
* The plain emitter (`reportSessionStart`) is dependency-free and lives in the CORE barrel too;
|
|
5
|
+
* the `useSessionStart` hook (React + AppState) is the batteries-included option. Both post the
|
|
6
|
+
* SAME `app.session_started` event into the stream the console/decision engines already read, so
|
|
7
|
+
* questionnaire firing (`min_count` / `min_sessions`) and per-user retention analytics come for
|
|
8
|
+
* free with ZERO server changes. See the README "Session mapping" section.
|
|
9
|
+
*/
|
|
10
|
+
export {
|
|
11
|
+
reportSessionStart,
|
|
12
|
+
resetSessionStartGuard,
|
|
13
|
+
SESSION_STARTED_EVENT,
|
|
14
|
+
} from "./reportSessionStart";
|
|
15
|
+
export type { ReportSessionStartOptions } from "./reportSessionStart";
|
|
16
|
+
|
|
17
|
+
export { useSessionStart, BACKGROUND_SESSION_MS } from "./useSessionStart";
|
|
18
|
+
export type { SessionStartConfig, UseSessionStartOptions } from "./useSessionStart";
|