@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,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last-known-flags cache — persisted through the host's optional `storage`.
|
|
3
|
+
*
|
|
4
|
+
* WHY: the fetch is best-effort and fail-open. When the control plane is unreachable we serve the
|
|
5
|
+
* LAST value we saw rather than snapping to defaults, so a tenant that intentionally disabled a
|
|
6
|
+
* module during an incident keeps it disabled across an outage instead of flapping back on. That
|
|
7
|
+
* is "stale-while-error": a fresh cache (< TTL) is trusted, and on a fetch error even a STALE
|
|
8
|
+
* cache is served (any age beats a guess). Every operation is best-effort — a broken/hung storage
|
|
9
|
+
* degrades to defaults, never a throw and never a stuck gate (mirrors persistedSession).
|
|
10
|
+
*/
|
|
11
|
+
import { parseWireFeatures } from "./defaults";
|
|
12
|
+
import type { WireFeatures } from "./types";
|
|
13
|
+
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* TTL for a cached flags entry, aligned to the server's `Cache-Control: private, max-age=300`.
|
|
17
|
+
* A cache younger than this is "fresh"; older is "stale" (still served on a fetch error).
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_FEATURES_TTL_MS = 300_000;
|
|
20
|
+
|
|
21
|
+
/** Ceiling on the storage read so a hung adapter degrades to defaults, never a stuck resolve. */
|
|
22
|
+
const READ_TIMEOUT_MS = 1_500;
|
|
23
|
+
|
|
24
|
+
/** Storage key for a tenant's cached flags, e.g. `wireai:features:myelino`. */
|
|
25
|
+
export const featuresCacheKey = (appId?: string): string =>
|
|
26
|
+
`wireai:features:${appId && appId.length > 0 ? appId : "default"}`;
|
|
27
|
+
|
|
28
|
+
/** A cached flags entry: the resolved flags + the epoch-ms they were fetched. */
|
|
29
|
+
export interface CachedFeatures {
|
|
30
|
+
features: WireFeatures;
|
|
31
|
+
/** Age of the entry in ms at read time. */
|
|
32
|
+
ageMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | undefined> => {
|
|
36
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
37
|
+
const timeout = new Promise<undefined>((resolve) => {
|
|
38
|
+
timer = setTimeout(() => resolve(undefined), ms);
|
|
39
|
+
});
|
|
40
|
+
return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Whether a cache entry is still within the TTL (server max-age aligned). */
|
|
44
|
+
export const isFeaturesFresh = (ageMs: number, ttlMs: number = DEFAULT_FEATURES_TTL_MS): boolean =>
|
|
45
|
+
ageMs >= 0 && ageMs < ttlMs;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read the cached flags for `key`, or `undefined` when absent/corrupt/timed-out. Returns the
|
|
49
|
+
* value AND its age so a caller can apply a TTL (fresh) or ignore it (stale-while-error). Never
|
|
50
|
+
* throws; never hangs past the read timeout. The stored value is re-parsed through
|
|
51
|
+
* `parseWireFeatures`, so a partially-corrupt entry still degrades fail-open.
|
|
52
|
+
*/
|
|
53
|
+
export const readCachedFeatures = async (
|
|
54
|
+
storage: WireOnboardingStorage,
|
|
55
|
+
key: string,
|
|
56
|
+
): Promise<CachedFeatures | undefined> => {
|
|
57
|
+
let raw: string | null | undefined;
|
|
58
|
+
try {
|
|
59
|
+
raw = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
if (!raw) return undefined;
|
|
64
|
+
try {
|
|
65
|
+
const parsed: unknown = JSON.parse(raw);
|
|
66
|
+
if (parsed && typeof parsed === "object" && typeof (parsed as { ts?: unknown }).ts === "number") {
|
|
67
|
+
const ts = (parsed as { ts: number }).ts;
|
|
68
|
+
return {
|
|
69
|
+
features: parseWireFeatures((parsed as { features?: unknown }).features),
|
|
70
|
+
ageMs: Date.now() - ts,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// Corrupt entry → treat as absent; the caller falls back to defaults / a fresh fetch.
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Persist `{ features, ts }` under `key` — fire-and-forget, all errors swallowed. */
|
|
80
|
+
export const writeCachedFeatures = (
|
|
81
|
+
storage: WireOnboardingStorage,
|
|
82
|
+
key: string,
|
|
83
|
+
features: WireFeatures,
|
|
84
|
+
): void => {
|
|
85
|
+
try {
|
|
86
|
+
void storage
|
|
87
|
+
.setItem(key, JSON.stringify({ features, ts: Date.now() }))
|
|
88
|
+
.catch(() => {});
|
|
89
|
+
} catch {
|
|
90
|
+
// Best-effort: a failed write just means the next launch has no cache to fall back to.
|
|
91
|
+
}
|
|
92
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fail-open defaults + a tolerant parser for the server's flags payload.
|
|
3
|
+
*
|
|
4
|
+
* THE CONTRACT: fail-open, never fail-dark. An absent server object, a missing module key, a
|
|
5
|
+
* malformed value, or an unreachable endpoint must resolve to the MOST PERMISSIVE state — every
|
|
6
|
+
* module ON, onboarding `ai` — so a control-plane outage can only ever be more permissive than
|
|
7
|
+
* the dashboard intends. It can never dark a tenant's activation. Matches the server's own
|
|
8
|
+
* documented fail-open default (FEATURES.md: "local defaults — every module on, onboarding ai").
|
|
9
|
+
*/
|
|
10
|
+
import type { OnboardingMode, WireFeatures } from "./types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ALL ON, onboarding `ai`. Frozen + module-level so its reference is stable across every call
|
|
14
|
+
* (consumers that list resolved flags in an effect dep array never loop on a fresh identity).
|
|
15
|
+
*/
|
|
16
|
+
export const defaultWireFeatures: WireFeatures = Object.freeze({
|
|
17
|
+
coachmarks: Object.freeze({ enabled: true }),
|
|
18
|
+
showcase: Object.freeze({ enabled: true }),
|
|
19
|
+
onboarding: Object.freeze({ mode: "ai" as OnboardingMode }),
|
|
20
|
+
review: Object.freeze({ enabled: true }),
|
|
21
|
+
questionnaire: Object.freeze({ enabled: true }),
|
|
22
|
+
announcements: Object.freeze({ enabled: true }),
|
|
23
|
+
}) as WireFeatures;
|
|
24
|
+
|
|
25
|
+
const VALID_MODES: readonly OnboardingMode[] = ["ai", "static", "off"];
|
|
26
|
+
|
|
27
|
+
/** Read a `{ enabled: boolean }` module, defaulting to ON when absent/malformed (fail-open). */
|
|
28
|
+
const readEnabled = (raw: unknown): { enabled: boolean } => {
|
|
29
|
+
if (raw && typeof raw === "object" && typeof (raw as { enabled?: unknown }).enabled === "boolean") {
|
|
30
|
+
return { enabled: (raw as { enabled: boolean }).enabled };
|
|
31
|
+
}
|
|
32
|
+
return { enabled: true };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Narrow the server's (untrusted) JSON to a complete `WireFeatures`. Every field is filled with
|
|
37
|
+
* its fail-open default when the server omits it or sends the wrong type, so a partial or
|
|
38
|
+
* malformed payload degrades PERMISSIVELY rather than throwing. A non-object input (null, array,
|
|
39
|
+
* string) → the full defaults. Never throws.
|
|
40
|
+
*/
|
|
41
|
+
export const parseWireFeatures = (raw: unknown): WireFeatures => {
|
|
42
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return defaultWireFeatures;
|
|
43
|
+
const src = raw as Record<string, unknown>;
|
|
44
|
+
const modeRaw = (src.onboarding as { mode?: unknown } | undefined)?.mode;
|
|
45
|
+
const mode: OnboardingMode =
|
|
46
|
+
typeof modeRaw === "string" && VALID_MODES.includes(modeRaw as OnboardingMode)
|
|
47
|
+
? (modeRaw as OnboardingMode)
|
|
48
|
+
: "ai";
|
|
49
|
+
return {
|
|
50
|
+
coachmarks: readEnabled(src.coachmarks),
|
|
51
|
+
showcase: readEnabled(src.showcase),
|
|
52
|
+
onboarding: { mode },
|
|
53
|
+
review: readEnabled(src.review),
|
|
54
|
+
questionnaire: readEnabled(src.questionnaire),
|
|
55
|
+
announcements: readEnabled(src.announcements),
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Value-equality of two resolved flags — lets the hook hold a stable reference across churn. */
|
|
60
|
+
export const featuresEqual = (a: WireFeatures, b: WireFeatures): boolean =>
|
|
61
|
+
a === b ||
|
|
62
|
+
(a.coachmarks.enabled === b.coachmarks.enabled &&
|
|
63
|
+
a.showcase.enabled === b.showcase.enabled &&
|
|
64
|
+
a.onboarding.mode === b.onboarding.mode &&
|
|
65
|
+
a.review.enabled === b.review.enabled &&
|
|
66
|
+
a.questionnaire.enabled === b.questionnaire.enabled &&
|
|
67
|
+
a.announcements.enabled === b.announcements.enabled);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetchWireFeatures — read the per-module kill switches from `GET {serverUrl}/v1/features`.
|
|
3
|
+
*
|
|
4
|
+
* Contract (server: chohra-med/wireai-onboarding-server, routers/onboarding.py):
|
|
5
|
+
* GET {serverUrl}/v1/features
|
|
6
|
+
* Authorization: Bearer {apiKey}
|
|
7
|
+
* 200 → { coachmarks:{enabled}, showcase:{enabled}, onboarding:{mode}, review:{enabled},
|
|
8
|
+
* announcements:{enabled} } (Cache-Control: private, max-age=300)
|
|
9
|
+
*
|
|
10
|
+
* THE CONTRACT — fail-open, never fail-dark. Unreachable, timed-out, unauthorized (401),
|
|
11
|
+
* server-error (5xx), or malformed JSON all resolve to the LAST CACHED value, else the
|
|
12
|
+
* all-on defaults. No throw ever reaches the host: this is timeout-capped and swallows every
|
|
13
|
+
* error, exactly like the session's best-effort reads. A control-plane outage can only ever be
|
|
14
|
+
* MORE permissive than the dashboard intends — it can never dark a tenant's activation.
|
|
15
|
+
*/
|
|
16
|
+
import { readCachedFeatures, featuresCacheKey, writeCachedFeatures } from "./cache";
|
|
17
|
+
import { defaultWireFeatures, parseWireFeatures } from "./defaults";
|
|
18
|
+
import type { WireFeatures, WireFeaturesConfig } from "./types";
|
|
19
|
+
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 4_000;
|
|
22
|
+
|
|
23
|
+
/** The fail-open fallback: last cached value (any age — stale-while-error), else defaults. */
|
|
24
|
+
const failOpen = async (
|
|
25
|
+
storage: WireOnboardingStorage | undefined,
|
|
26
|
+
key: string,
|
|
27
|
+
): Promise<WireFeatures> => {
|
|
28
|
+
if (!storage) return defaultWireFeatures;
|
|
29
|
+
const cached = await readCachedFeatures(storage, key);
|
|
30
|
+
return cached?.features ?? defaultWireFeatures;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** GET with a hard timeout via AbortController (falls back to no-abort where unsupported). */
|
|
34
|
+
const fetchWithTimeout = async (
|
|
35
|
+
url: string,
|
|
36
|
+
apiKey: string,
|
|
37
|
+
timeoutMs: number,
|
|
38
|
+
): Promise<Response> => {
|
|
39
|
+
const controller =
|
|
40
|
+
typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
41
|
+
const timer = setTimeout(() => controller?.abort(), timeoutMs);
|
|
42
|
+
try {
|
|
43
|
+
return await fetch(url, {
|
|
44
|
+
method: "GET",
|
|
45
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
46
|
+
signal: controller?.signal,
|
|
47
|
+
});
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the tenant's feature flags. Attempts the network once; on ANY failure returns the
|
|
55
|
+
* cached value (any age), else the all-on defaults. On success, persists the fresh value to the
|
|
56
|
+
* cache (fire-and-forget) and returns it. Never throws.
|
|
57
|
+
*
|
|
58
|
+
* const flags = await fetchWireFeatures({ serverUrl, apiKey, appId, storage });
|
|
59
|
+
* if (!flags.coachmarks.enabled) { /* skip the tour * / }
|
|
60
|
+
*/
|
|
61
|
+
export const fetchWireFeatures = async (
|
|
62
|
+
config?: WireFeaturesConfig,
|
|
63
|
+
): Promise<WireFeatures> => {
|
|
64
|
+
const key = featuresCacheKey(config?.appId);
|
|
65
|
+
const storage = config?.storage;
|
|
66
|
+
|
|
67
|
+
// Nothing to fetch (no creds) → cache, else defaults. Lets a host mount the provider before
|
|
68
|
+
// its config is ready without ever darking.
|
|
69
|
+
if (!config?.serverUrl || !config?.apiKey) return failOpen(storage, key);
|
|
70
|
+
|
|
71
|
+
const url = `${config.serverUrl.replace(/\/$/, "")}/v1/features`;
|
|
72
|
+
const timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetchWithTimeout(url, config.apiKey, timeoutMs);
|
|
76
|
+
// 401 (bad/rotated key), 5xx, any non-2xx → last-known, never dark.
|
|
77
|
+
if (!res || !res.ok) return failOpen(storage, key);
|
|
78
|
+
const json: unknown = await res.json(); // throws on malformed body → caught below
|
|
79
|
+
const features = parseWireFeatures(json);
|
|
80
|
+
if (storage) writeCachedFeatures(storage, key, features);
|
|
81
|
+
return features;
|
|
82
|
+
} catch {
|
|
83
|
+
// Timeout/abort, network error, missing fetch, or malformed JSON — all fail open.
|
|
84
|
+
return failOpen(storage, key);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wireai-onboarding feature controls — the per-module dashboard kill switches, consumed by the
|
|
3
|
+
* kit with a HARD fail-open contract (a control-plane outage can never dark your app).
|
|
4
|
+
*
|
|
5
|
+
* These live in the CORE barrel (dependency-free: types + fetch + a React hook/provider). The
|
|
6
|
+
* GATING is wired into the three client surfaces (coachmarks / showcase / reviews); onboarding
|
|
7
|
+
* mode is server-owned, so the kit needs no gating there.
|
|
8
|
+
*
|
|
9
|
+
* import { WireFeaturesProvider, fetchWireFeatures } from "wireai-onboarding";
|
|
10
|
+
*/
|
|
11
|
+
export { fetchWireFeatures } from "./fetchWireFeatures";
|
|
12
|
+
export { useWireFeatures } from "./useWireFeatures";
|
|
13
|
+
export {
|
|
14
|
+
WireFeaturesProvider,
|
|
15
|
+
useWireFeaturesContext,
|
|
16
|
+
useResolvedFeatures,
|
|
17
|
+
} from "./WireFeaturesProvider";
|
|
18
|
+
export type {
|
|
19
|
+
WireFeaturesProviderProps,
|
|
20
|
+
ResolveFeaturesOptions,
|
|
21
|
+
} from "./WireFeaturesProvider";
|
|
22
|
+
export {
|
|
23
|
+
defaultWireFeatures,
|
|
24
|
+
parseWireFeatures,
|
|
25
|
+
featuresEqual,
|
|
26
|
+
} from "./defaults";
|
|
27
|
+
export {
|
|
28
|
+
featuresCacheKey,
|
|
29
|
+
readCachedFeatures,
|
|
30
|
+
writeCachedFeatures,
|
|
31
|
+
isFeaturesFresh,
|
|
32
|
+
DEFAULT_FEATURES_TTL_MS,
|
|
33
|
+
} from "./cache";
|
|
34
|
+
export type { CachedFeatures } from "./cache";
|
|
35
|
+
export type {
|
|
36
|
+
WireFeatures,
|
|
37
|
+
WireFeaturesConfig,
|
|
38
|
+
OnboardingMode,
|
|
39
|
+
} from "./types";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for the feature-controls module — the per-module dashboard kill switches.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the server's `GET /v1/features` response (chohra-med/wireai-onboarding-server):
|
|
5
|
+
* one switch per activation surface, all fail-open. This file is dependency-free (types +
|
|
6
|
+
* the frozen default), so it stays in the CORE barrel — the gating lives in the dep-heavy
|
|
7
|
+
* subpaths (coachmarks / showcase / reviews) that consume these flags.
|
|
8
|
+
*/
|
|
9
|
+
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The onboarding module's three-way control. The kit needs NO gating for these: the SERVER
|
|
13
|
+
* owns the branch (`ai` = LLM flow, `static` = scripted zero-LLM flow over the same A2A
|
|
14
|
+
* contract, `off` = a terminal StatusCard the kit already reads as completion). Surfaced here
|
|
15
|
+
* only so a host can read the current mode. See the README "Onboarding mode" note.
|
|
16
|
+
*/
|
|
17
|
+
export type OnboardingMode = "ai" | "static" | "off";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The resolved controls object. Every field is always present after parsing (missing server
|
|
21
|
+
* keys are filled with the fail-open default), so consumers read `flags.coachmarks.enabled`
|
|
22
|
+
* without guarding for absence.
|
|
23
|
+
*/
|
|
24
|
+
export interface WireFeatures {
|
|
25
|
+
/** Coachmarks / gesture hints (kit-gated in CoachmarkProvider + the overlay host). */
|
|
26
|
+
coachmarks: { enabled: boolean };
|
|
27
|
+
/** Pre-onboarding + post-onboarding value bridge (kit-gated in FeatureShowcase). */
|
|
28
|
+
showcase: { enabled: boolean };
|
|
29
|
+
/** Onboarding flow. Server-owned branch — informational for the kit (no gating). */
|
|
30
|
+
onboarding: { mode: OnboardingMode };
|
|
31
|
+
/** In-app review master switch (kit-gated in useReviewGate / ReviewGate). */
|
|
32
|
+
review: { enabled: boolean };
|
|
33
|
+
/** Pre-onboarding questionnaire master switch (kit-gated in the questionnaire module). */
|
|
34
|
+
questionnaire: { enabled: boolean };
|
|
35
|
+
/** Reserved — the announcements module is not built yet. No kit surface reads it. */
|
|
36
|
+
announcements: { enabled: boolean };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What `fetchWireFeatures` / `useWireFeatures` need to reach the control plane. Same tenant
|
|
41
|
+
* creds as the onboarding flow (`WireOnboardingConfig.serverUrl` + `apiKey`); `storage` is the
|
|
42
|
+
* OPTIONAL last-known cache channel (the same AsyncStorage-compatible subset the session uses).
|
|
43
|
+
*/
|
|
44
|
+
export interface WireFeaturesConfig {
|
|
45
|
+
/** Base server URL (same as `WireOnboardingConfig.serverUrl`); `/v1/features` is appended. */
|
|
46
|
+
serverUrl?: string;
|
|
47
|
+
/** Tenant API key; sent as `Authorization: Bearer`. */
|
|
48
|
+
apiKey?: string;
|
|
49
|
+
/**
|
|
50
|
+
* App id — scopes the cache key so one device caching flags for two tenants never collides.
|
|
51
|
+
* Optional; falls back to a shared `default` bucket when absent.
|
|
52
|
+
*/
|
|
53
|
+
appId?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Host-injected storage (AsyncStorage/MMKV subset) for the last-known-flags cache. Omit and
|
|
56
|
+
* the module still works — it just has no cache to fall back to (defaults on failure).
|
|
57
|
+
*/
|
|
58
|
+
storage?: WireOnboardingStorage;
|
|
59
|
+
/** Network timeout in ms before the fetch is abandoned to the fail-open fallback. Default 4000. */
|
|
60
|
+
timeoutMs?: number;
|
|
61
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useWireFeatures — resolve the tenant's feature flags for a component tree, once per mount.
|
|
3
|
+
*
|
|
4
|
+
* Returns the all-on defaults immediately (fail-open), then swaps to the fetched value when the
|
|
5
|
+
* network resolves. When no `serverUrl`+`apiKey` is supplied it never fetches and just returns
|
|
6
|
+
* defaults, so a host that never adopts flags sees ZERO behavior change and ZERO network calls.
|
|
7
|
+
*
|
|
8
|
+
* frequent_rules #11 (unstable inline props): the config is idiomatically passed inline
|
|
9
|
+
* (`config={{ serverUrl, apiKey }}`), a fresh identity every render. We stabilize the fetch key
|
|
10
|
+
* by VALUE so the effect fires once per real credential change, and we only swap the returned
|
|
11
|
+
* flags object when its VALUES change — so a host that lists the returned flags in an effect dep
|
|
12
|
+
* array settles in a bounded number of commits instead of looping (enforced by the canary suite).
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef, useState } from "react";
|
|
15
|
+
|
|
16
|
+
import { defaultWireFeatures, featuresEqual } from "./defaults";
|
|
17
|
+
import { fetchWireFeatures } from "./fetchWireFeatures";
|
|
18
|
+
import type { WireFeatures, WireFeaturesConfig } from "./types";
|
|
19
|
+
|
|
20
|
+
/** Hold a stable reference for a value while its CONTENT is unchanged (see reviews/equality.ts). */
|
|
21
|
+
const useStableValue = <T,>(value: T, isEqual: (a: T, b: T) => boolean): T => {
|
|
22
|
+
const ref = useRef(value);
|
|
23
|
+
if (!isEqual(ref.current, value)) ref.current = value;
|
|
24
|
+
return ref.current;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The credential slice that actually drives a refetch — stabilized by value. */
|
|
28
|
+
const sameFetchKey = (a: WireFeaturesConfig | undefined, b: WireFeaturesConfig | undefined): boolean =>
|
|
29
|
+
a?.serverUrl === b?.serverUrl && a?.apiKey === b?.apiKey && a?.appId === b?.appId;
|
|
30
|
+
|
|
31
|
+
export const useWireFeatures = (config?: WireFeaturesConfig): WireFeatures => {
|
|
32
|
+
const stableConfig = useStableValue(config, sameFetchKey);
|
|
33
|
+
const [flags, setFlags] = useState<WireFeatures>(defaultWireFeatures);
|
|
34
|
+
|
|
35
|
+
const canFetch = !!stableConfig?.serverUrl && !!stableConfig?.apiKey;
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!canFetch) {
|
|
39
|
+
// No creds → stay on the all-on defaults (and reset if creds were removed).
|
|
40
|
+
setFlags((prev) => (featuresEqual(prev, defaultWireFeatures) ? prev : defaultWireFeatures));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let alive = true;
|
|
44
|
+
// fetchWireFeatures never throws — the .then always runs with a fail-open value.
|
|
45
|
+
void fetchWireFeatures(stableConfig).then((next) => {
|
|
46
|
+
if (!alive) return;
|
|
47
|
+
// Only adopt a NEW reference on a real value change, so a host effect keyed on the
|
|
48
|
+
// returned flags doesn't loop (frequent_rules #11).
|
|
49
|
+
setFlags((prev) => (featuresEqual(prev, next) ? prev : next));
|
|
50
|
+
});
|
|
51
|
+
return () => {
|
|
52
|
+
alive = false;
|
|
53
|
+
};
|
|
54
|
+
}, [canFetch, stableConfig]);
|
|
55
|
+
|
|
56
|
+
return flags;
|
|
57
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User identity — bind an onboarding session to the HOST's own user id so the funnel can
|
|
3
|
+
* be reconciled to real users later (console sessions ↔ your user table / GA4 users).
|
|
4
|
+
*
|
|
5
|
+
* The id is an OPAQUE PSEUDONYMOUS STRING the host owns — its internal user id, NOT an
|
|
6
|
+
* email/name/phone. Same host-injection philosophy as `storage` and `userContext`: the kit
|
|
7
|
+
* mints nothing and reads nothing; the host passes what it wants. Dependency-free.
|
|
8
|
+
*
|
|
9
|
+
* Three binding moments (see WireOnboarding + the README "User identity" section):
|
|
10
|
+
* 1. MOUNT — pass `userId` and it rides the A2A session-start metadata; the server
|
|
11
|
+
* binds it when it creates the session.
|
|
12
|
+
* 2. MID-SESSION— the user registers DURING onboarding: change the `userId` prop and the
|
|
13
|
+
* kit emits an `identify` client event that attaches the id to the live
|
|
14
|
+
* session (late binding — the key scenario).
|
|
15
|
+
* 3. POST-FLOW — the user registers AFTER onboarding: call `identifyOnboarding(...)` with
|
|
16
|
+
* the same host storage (it recovers the persisted contextId, before completion
|
|
17
|
+
* clears it) or, more reliably, the `contextId` you captured from the
|
|
18
|
+
* `started`/`resumed` `onEvent` (both events carry a `contextId` field).
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ NO PII. Pass an opaque id (or a hash), never a raw email/name/phone. The id is capped at
|
|
21
|
+
* {@link USER_ID_MAX_LENGTH} chars (longer ids are truncated, not rejected).
|
|
22
|
+
*/
|
|
23
|
+
import { reportClientEvent } from "../analytics/reportClientEvent";
|
|
24
|
+
import {
|
|
25
|
+
peekPersistedSession,
|
|
26
|
+
sessionStorageKey,
|
|
27
|
+
type WireOnboardingStorage,
|
|
28
|
+
} from "../session/persistedSession";
|
|
29
|
+
|
|
30
|
+
/** Max accepted user-id length. Longer strings are truncated (never rejected). Keep in sync
|
|
31
|
+
* with the server's `USER_ID_MAX_LENGTH` (analytics/events.py). */
|
|
32
|
+
export const USER_ID_MAX_LENGTH = 128;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalize a host-supplied user id: trim, drop empty, and cap at {@link USER_ID_MAX_LENGTH}.
|
|
36
|
+
* Returns `undefined` for a missing/blank/non-string value so callers can `if (id)`-gate.
|
|
37
|
+
* PII is a host concern — this only bounds length, it does not (and cannot) detect an email.
|
|
38
|
+
*/
|
|
39
|
+
export const sanitizeUserId = (raw: unknown): string | undefined => {
|
|
40
|
+
if (typeof raw !== "string") return undefined;
|
|
41
|
+
const trimmed = raw.trim();
|
|
42
|
+
if (!trimmed) return undefined;
|
|
43
|
+
return trimmed.length > USER_ID_MAX_LENGTH ? trimmed.slice(0, USER_ID_MAX_LENGTH) : trimmed;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Options for {@link identifyOnboarding}. */
|
|
47
|
+
export type IdentifyOnboardingOptions = {
|
|
48
|
+
/** Tenant transport, same shape as `WireOnboardingConfig` (only these two fields are used). */
|
|
49
|
+
config: { serverUrl: string; apiKey: string };
|
|
50
|
+
/** The host's opaque user id to bind. Trimmed + capped; NO PII. */
|
|
51
|
+
userId: string;
|
|
52
|
+
/**
|
|
53
|
+
* The onboarding session id to bind to (the A2A contextId) — the `contextId` field carried on
|
|
54
|
+
* the `started`/`resumed` `onEvent`. Pass this when you captured it there. Required after the
|
|
55
|
+
* flow COMPLETED, since completion clears the persisted session. Wins over the storage lookup.
|
|
56
|
+
*/
|
|
57
|
+
contextId?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The SAME host storage you passed to `<WireOnboarding storage={…} />`. When `contextId` is
|
|
60
|
+
* omitted, the helper reads the persisted contextId from it (works while the session is still
|
|
61
|
+
* persisted — i.e. dropped or mid-flow, before completion clears it).
|
|
62
|
+
*/
|
|
63
|
+
storage?: WireOnboardingStorage;
|
|
64
|
+
/** App id, to derive the default storage key `wireai:session:<appId>` when reading from storage. */
|
|
65
|
+
appId?: string;
|
|
66
|
+
/** Storage key override — pass the same `persistKey` you gave `<WireOnboarding>`, if any. */
|
|
67
|
+
persistKey?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Attach a host user id to an onboarding session AFTER the fact (post-registration), by sending
|
|
72
|
+
* an `identify` client event to `/v1/events`. Resolves the contextId from an explicit
|
|
73
|
+
* `contextId` or, failing that, from the persisted session in the host `storage`.
|
|
74
|
+
*
|
|
75
|
+
* Fire-and-forget under the hood (never throws, never blocks onboarding). Resolves to `true`
|
|
76
|
+
* when an identify event was dispatched, `false` when it couldn't (no user id, no server url,
|
|
77
|
+
* or no resolvable contextId).
|
|
78
|
+
*/
|
|
79
|
+
export const identifyOnboarding = async (
|
|
80
|
+
opts: IdentifyOnboardingOptions,
|
|
81
|
+
): Promise<boolean> => {
|
|
82
|
+
const userId = sanitizeUserId(opts.userId);
|
|
83
|
+
if (!userId || !opts.config?.serverUrl) return false;
|
|
84
|
+
|
|
85
|
+
let contextId = opts.contextId?.trim() || undefined;
|
|
86
|
+
if (!contextId && opts.storage) {
|
|
87
|
+
const key = opts.persistKey ?? (opts.appId ? sessionStorageKey(opts.appId) : undefined);
|
|
88
|
+
if (key) {
|
|
89
|
+
const stored = await peekPersistedSession(opts.storage, key);
|
|
90
|
+
contextId = stored?.id;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!contextId) return false;
|
|
94
|
+
|
|
95
|
+
reportClientEvent(
|
|
96
|
+
{ serverUrl: opts.config.serverUrl, apiKey: opts.config.apiKey },
|
|
97
|
+
{ event_type: "identify", session_id: contextId, user_id: userId },
|
|
98
|
+
);
|
|
99
|
+
return true;
|
|
100
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* defaultIllustrations — a tiny, dependency-free fallback registry so an app can
|
|
3
|
+
* render `InterstitialCard` with ZERO illustration code. These are plain
|
|
4
|
+
* <View>/<Text> glyphs (NO react-native-svg, NO Lottie, NO image assets) themed
|
|
5
|
+
* from the active OnboardingTheme, covering the common backend-emitted names.
|
|
6
|
+
*
|
|
7
|
+
* Spread it under your own artwork — your named nodes win:
|
|
8
|
+
*
|
|
9
|
+
* illustrations={{ ...defaultIllustrations, ...myIllustrations }}
|
|
10
|
+
*
|
|
11
|
+
* Names match what the onboarding backend commonly emits via an InterstitialCard's
|
|
12
|
+
* `illustration`: `momentum` (forward-motion arc) and `before-after` (two states).
|
|
13
|
+
* Add more by spreading your own — these are only sensible defaults.
|
|
14
|
+
*/
|
|
15
|
+
import React from "react";
|
|
16
|
+
import { StyleSheet, Text, View } from "react-native";
|
|
17
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
18
|
+
|
|
19
|
+
/** A soft tinted disc with a centered glyph — the shared frame for every default. */
|
|
20
|
+
const Glyph: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
21
|
+
const t = useOnboardingTheme();
|
|
22
|
+
return (
|
|
23
|
+
<View
|
|
24
|
+
style={[
|
|
25
|
+
styles.disc,
|
|
26
|
+
{ backgroundColor: t.colors.primarySoft, borderRadius: t.radius.full },
|
|
27
|
+
]}
|
|
28
|
+
>
|
|
29
|
+
<Text style={[styles.glyphText, { color: t.colors.primary }]} allowFontScaling={false}>
|
|
30
|
+
{children}
|
|
31
|
+
</Text>
|
|
32
|
+
</View>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Forward-motion / momentum: an upward arrow. */
|
|
37
|
+
const MomentumGlyph: React.FC = () => <Glyph>{"↗"}</Glyph>;
|
|
38
|
+
|
|
39
|
+
/** Before → after: two states with an arrow between. */
|
|
40
|
+
const BeforeAfterGlyph: React.FC = () => {
|
|
41
|
+
const t = useOnboardingTheme();
|
|
42
|
+
return (
|
|
43
|
+
<View style={styles.row}>
|
|
44
|
+
<View
|
|
45
|
+
style={[
|
|
46
|
+
styles.chip,
|
|
47
|
+
{ borderColor: t.colors.border, borderRadius: t.radius.md },
|
|
48
|
+
]}
|
|
49
|
+
/>
|
|
50
|
+
<Text style={[styles.arrow, { color: t.colors.primary }]} allowFontScaling={false}>
|
|
51
|
+
{"→"}
|
|
52
|
+
</Text>
|
|
53
|
+
<View
|
|
54
|
+
style={[
|
|
55
|
+
styles.chip,
|
|
56
|
+
{ backgroundColor: t.colors.primarySoft, borderColor: t.colors.primary, borderRadius: t.radius.md },
|
|
57
|
+
]}
|
|
58
|
+
/>
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The default registry. `WireOnboarding`'s `illustrations` prop is
|
|
65
|
+
* `Record<string, ReactNode>`, so these are concrete element nodes.
|
|
66
|
+
*/
|
|
67
|
+
export const defaultIllustrations: Record<string, React.ReactNode> = {
|
|
68
|
+
momentum: <MomentumGlyph />,
|
|
69
|
+
"before-after": <BeforeAfterGlyph />,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const styles = StyleSheet.create({
|
|
73
|
+
disc: {
|
|
74
|
+
width: 96,
|
|
75
|
+
height: 96,
|
|
76
|
+
alignItems: "center",
|
|
77
|
+
justifyContent: "center",
|
|
78
|
+
},
|
|
79
|
+
glyphText: {
|
|
80
|
+
fontSize: 44,
|
|
81
|
+
fontWeight: "700",
|
|
82
|
+
lineHeight: 52,
|
|
83
|
+
},
|
|
84
|
+
row: {
|
|
85
|
+
flexDirection: "row",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
justifyContent: "center",
|
|
88
|
+
gap: 12,
|
|
89
|
+
},
|
|
90
|
+
chip: {
|
|
91
|
+
width: 56,
|
|
92
|
+
height: 56,
|
|
93
|
+
borderWidth: 2,
|
|
94
|
+
},
|
|
95
|
+
arrow: {
|
|
96
|
+
fontSize: 28,
|
|
97
|
+
fontWeight: "700",
|
|
98
|
+
},
|
|
99
|
+
});
|