@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,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wireai-onboarding/questionnaire - the skippable pre-onboarding questionnaire popup.
|
|
3
|
+
*
|
|
4
|
+
* Subpath entry, kept OUT of the main barrel so the core kit stays dependency-free. The gate
|
|
5
|
+
* asks one mandatory question ("what do you think about the app") plus two optional boxes,
|
|
6
|
+
* POSTs the answers to the Wire server, and decides WHEN to show via local rules OR a
|
|
7
|
+
* server-provided decision (the AI seam), gated by the `questionnaire` master switch and shown
|
|
8
|
+
* at most once per user per questionnaire id. It reuses the shared `CenteredModal` shell (never
|
|
9
|
+
* importing reviews/ internals for UI).
|
|
10
|
+
*
|
|
11
|
+
* import { QuestionnaireGate, useQuestionnaireGate } from "wireai-onboarding/questionnaire";
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ─── The popup UI ───────────────────────────────────────────────────────────────
|
|
15
|
+
export { QuestionnaireGate } from "./QuestionnaireGate";
|
|
16
|
+
|
|
17
|
+
// ─── The WHEN decision (local rules + the server-decision override) ─────────────
|
|
18
|
+
export { useQuestionnaireGate } from "./useQuestionnaireGate";
|
|
19
|
+
|
|
20
|
+
// ─── The AI seam + pure gate logic (verifiable in isolation) ────────────────────
|
|
21
|
+
export {
|
|
22
|
+
decideQuestionnaire,
|
|
23
|
+
evaluateGate,
|
|
24
|
+
resolveRules,
|
|
25
|
+
buildQuestionnaireSubmission,
|
|
26
|
+
} from "./decision";
|
|
27
|
+
export type { GateSignals, GateRules } from "./decision";
|
|
28
|
+
|
|
29
|
+
// ─── Server transport: submit a response + best-effort fetch the decision ───────
|
|
30
|
+
export { submitQuestionnaireResponse, fetchQuestionnaireDecision } from "./transport";
|
|
31
|
+
export type { FetchQuestionnaireDecisionOptions } from "./transport";
|
|
32
|
+
|
|
33
|
+
// ─── Gate-key helpers (shared coachmark storage singleton) ──────────────────────
|
|
34
|
+
export {
|
|
35
|
+
questionnaireSeenKey,
|
|
36
|
+
questionnaireLastShownKey,
|
|
37
|
+
questionnaireSessionsKey,
|
|
38
|
+
} from "./runtime";
|
|
39
|
+
|
|
40
|
+
// ─── Types ──────────────────────────────────────────────────────────────────────
|
|
41
|
+
export type {
|
|
42
|
+
QuestionnaireDecision,
|
|
43
|
+
QuestionnaireDefinition,
|
|
44
|
+
QuestionnaireAnswers,
|
|
45
|
+
QuestionnaireMeta,
|
|
46
|
+
QuestionnaireSubmission,
|
|
47
|
+
QuestionnaireDecisionResponse,
|
|
48
|
+
QuestionnaireTarget,
|
|
49
|
+
QuestionnaireGateProps,
|
|
50
|
+
QuestionnaireGateEvent,
|
|
51
|
+
QuestionnaireGateEventName,
|
|
52
|
+
UseQuestionnaireGateOptions,
|
|
53
|
+
QuestionnaireGateController,
|
|
54
|
+
} from "./types";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime helpers for the questionnaire gate: the `wire_questionnaire_*` gate keys. The
|
|
3
|
+
* gate reuses the SAME injected sync storage + global testing flag as the coachmark engine
|
|
4
|
+
* (via `resolveStorage`/`readInt`/`writeInt`, re-exported from the reviews runtime), so a
|
|
5
|
+
* host that mounted CoachmarkProvider gets questionnaire gating for free - no second storage
|
|
6
|
+
* to wire. Once-per-user is keyed on the questionnaire `id`.
|
|
7
|
+
*/
|
|
8
|
+
export { resolveStorage, readInt, writeInt } from "../reviews/runtime";
|
|
9
|
+
|
|
10
|
+
/** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
|
|
11
|
+
export const questionnaireSeenKey = (id: string, version?: string): string =>
|
|
12
|
+
version ? `wire_questionnaire_${id}_${version}_seen` : `wire_questionnaire_${id}_seen`;
|
|
13
|
+
|
|
14
|
+
/** Last-shown timestamp key (epoch ms), for the cooldown rule. */
|
|
15
|
+
export const questionnaireLastShownKey = (id: string): string =>
|
|
16
|
+
`wire_questionnaire_${id}_last`;
|
|
17
|
+
|
|
18
|
+
/** Session-count key, incremented once per gate mount, for the min-sessions rule. */
|
|
19
|
+
export const questionnaireSessionsKey = (id: string): string =>
|
|
20
|
+
`wire_questionnaire_${id}_sessions`;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transport.ts - kit → Wire server requests for the questionnaire module, all fire-and-forget
|
|
3
|
+
* (a pre-onboarding questionnaire must NEVER break the app). Mirrors `submitReview`: a thin
|
|
4
|
+
* fetch wrapper, Bearer tenant key, swallow every error.
|
|
5
|
+
*
|
|
6
|
+
* • submitQuestionnaireResponse → POST {serverUrl}/v1/questionnaires/{id}/responses
|
|
7
|
+
* • fetchQuestionnaireDecision → GET {serverUrl}/v1/questionnaires/decision (best-effort)
|
|
8
|
+
*
|
|
9
|
+
* The decision fetch is HARD fail-open on the "never show" side: an unreachable endpoint, a
|
|
10
|
+
* non-2xx (incl. 404), a missing `fetch`, or bad JSON all resolve to null, so a host wiring
|
|
11
|
+
* pure server-directed firing simply shows nothing - exactly the reviews decision seam.
|
|
12
|
+
*/
|
|
13
|
+
import type {
|
|
14
|
+
QuestionnaireDecisionResponse,
|
|
15
|
+
QuestionnaireSubmission,
|
|
16
|
+
QuestionnaireTarget,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* POST a questionnaire response. Fire-and-forget: a missing target, a missing id, a build
|
|
21
|
+
* error, a missing `fetch`, or a network failure is swallowed and the call returns immediately.
|
|
22
|
+
*/
|
|
23
|
+
export const submitQuestionnaireResponse = (
|
|
24
|
+
target: QuestionnaireTarget | undefined,
|
|
25
|
+
id: string,
|
|
26
|
+
submission: QuestionnaireSubmission,
|
|
27
|
+
): void => {
|
|
28
|
+
if (!target?.serverUrl || !id) return;
|
|
29
|
+
try {
|
|
30
|
+
const base = target.serverUrl.replace(/\/$/, "");
|
|
31
|
+
const url = `${base}/v1/questionnaires/${encodeURIComponent(id)}/responses`;
|
|
32
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
33
|
+
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
34
|
+
void fetch(url, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers,
|
|
37
|
+
body: JSON.stringify(submission),
|
|
38
|
+
}).catch(() => {
|
|
39
|
+
/* best-effort, swallow */
|
|
40
|
+
});
|
|
41
|
+
} catch {
|
|
42
|
+
/* URL/JSON/missing-fetch - swallow */
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Options for the best-effort decision fetch. `deviceKey` groups a device's sessions server-side. */
|
|
47
|
+
export interface FetchQuestionnaireDecisionOptions {
|
|
48
|
+
/** The onboarding/session id to correlate with, when known. */
|
|
49
|
+
sessionId?: string;
|
|
50
|
+
/** A stable, non-PII device id - the decision endpoint reads it for min-sessions. */
|
|
51
|
+
deviceKey?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Best-effort fetch of the server firing decision. Returns the parsed
|
|
56
|
+
* `{ fire, reason, questionnaire }` on a 2xx, else null. NEVER throws - any failure resolves
|
|
57
|
+
* to null (fail-safe: the host shows nothing). The kit does not call this internally; a host
|
|
58
|
+
* that wants server-directed firing awaits it and passes the result to `useQuestionnaireGate`
|
|
59
|
+
* (the same AI seam the reviews gate uses).
|
|
60
|
+
*/
|
|
61
|
+
export const fetchQuestionnaireDecision = async (
|
|
62
|
+
target: QuestionnaireTarget | undefined,
|
|
63
|
+
options: FetchQuestionnaireDecisionOptions = {},
|
|
64
|
+
): Promise<QuestionnaireDecisionResponse | null> => {
|
|
65
|
+
if (!target?.serverUrl) return null;
|
|
66
|
+
try {
|
|
67
|
+
const base = target.serverUrl.replace(/\/$/, "");
|
|
68
|
+
const params = new URLSearchParams();
|
|
69
|
+
if (options.sessionId) params.set("session_id", options.sessionId);
|
|
70
|
+
if (options.deviceKey) params.set("device_key", options.deviceKey);
|
|
71
|
+
const qs = params.toString();
|
|
72
|
+
const url = `${base}/v1/questionnaires/decision${qs ? `?${qs}` : ""}`;
|
|
73
|
+
const headers: Record<string, string> = {};
|
|
74
|
+
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
75
|
+
const res = await fetch(url, { headers });
|
|
76
|
+
if (!res || !res.ok) return null;
|
|
77
|
+
const json = (await res.json()) as QuestionnaireDecisionResponse | null;
|
|
78
|
+
return json ?? null;
|
|
79
|
+
} catch {
|
|
80
|
+
/* unreachable / non-2xx / bad JSON / missing-fetch - never show */
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the questionnaire module (the skippable pre-onboarding popup).
|
|
3
|
+
*
|
|
4
|
+
* The gate asks a short set of questions in a centered popup: ONE mandatory free-text
|
|
5
|
+
* "what do you think about the app" (the opinion), plus two OPTIONAL boxes ("help us
|
|
6
|
+
* improve" + "suggestions"). WHEN the gate shows is decided by local rules OR a
|
|
7
|
+
* server-provided decision (the AI seam), gated by the `questionnaire` master switch and
|
|
8
|
+
* shown at most once per user per questionnaire `id`. Dependency-free type surface.
|
|
9
|
+
*/
|
|
10
|
+
import type { CoachmarkStorage } from "../coachmarks/types";
|
|
11
|
+
import type { WireFeatures, WireFeaturesConfig } from "../features/types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The firing verdict - the SAME contract on both sides of the seam. `decideQuestionnaire`
|
|
15
|
+
* turns local rules into one of these; a server `{fire, reason}` (from
|
|
16
|
+
* GET /v1/questionnaires/decision) is the exact same shape and OVERRIDES the local one.
|
|
17
|
+
*/
|
|
18
|
+
export interface QuestionnaireDecision {
|
|
19
|
+
fire: boolean;
|
|
20
|
+
reason?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The questionnaire the kit renders. Comes from the server decision (`questionnaire`
|
|
25
|
+
* field) or a local literal. `id` is required (the once-gate + keys derive from it); the
|
|
26
|
+
* labels and title are optional (sensible defaults render when absent). The optional
|
|
27
|
+
* firing-rule fields are the LOCAL fallback the decision hook evaluates when no server
|
|
28
|
+
* decision is passed, mirroring `ReviewConfig`.
|
|
29
|
+
*/
|
|
30
|
+
export interface QuestionnaireDefinition {
|
|
31
|
+
/** Gate key. Seen/last-shown/session keys derive from it (`wire_questionnaire_<id>_*`). */
|
|
32
|
+
id: string;
|
|
33
|
+
/** Popup heading. Defaults to a neutral prompt when absent. */
|
|
34
|
+
title?: string;
|
|
35
|
+
/** Label for the MANDATORY opinion box ("what do you think about the app"). */
|
|
36
|
+
opinion_label?: string;
|
|
37
|
+
/** Label for the first OPTIONAL box ("help us improve"). */
|
|
38
|
+
improve_label?: string;
|
|
39
|
+
/** Label for the second OPTIONAL box ("suggestions"). */
|
|
40
|
+
suggestions_label?: string;
|
|
41
|
+
|
|
42
|
+
// ── Local fallback firing rules (used only when no server decision is passed) ──
|
|
43
|
+
/** Master local switch. Default true. */
|
|
44
|
+
enabled?: boolean;
|
|
45
|
+
/** Require at least this many sessions on the device before showing. Default 0. */
|
|
46
|
+
minSessions?: number;
|
|
47
|
+
/** Require at least this many tracked app events before showing. Default 0. */
|
|
48
|
+
minEvents?: number;
|
|
49
|
+
/** Suppress a re-prompt within this many days of the last shown. Default 0 (off). */
|
|
50
|
+
cooldownDays?: number;
|
|
51
|
+
/** Show at most once per app version (seen-gate keyed by `appVersion`). Default true. */
|
|
52
|
+
oncePerVersion?: boolean;
|
|
53
|
+
/** The current app version, for the once-per-version seen gate. */
|
|
54
|
+
appVersion?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Client fallback ONLY: if no server decision arrives within this many ms of mount,
|
|
57
|
+
* fall back to the local rules. Null/absent → evaluate local rules immediately.
|
|
58
|
+
*/
|
|
59
|
+
timeoutFallbackMs?: number | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The user's three answers. `opinion` is the only mandatory one. */
|
|
63
|
+
export interface QuestionnaireAnswers {
|
|
64
|
+
/** The mandatory "what do you think about the app" free text. */
|
|
65
|
+
opinion: string;
|
|
66
|
+
/** The optional "help us improve" free text. */
|
|
67
|
+
improve?: string;
|
|
68
|
+
/** The optional "suggestions" free text. */
|
|
69
|
+
suggestions?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Non-PII correlation extras sent with a submission (the `meta` bucket in the POST body). */
|
|
73
|
+
export interface QuestionnaireMeta {
|
|
74
|
+
user_id?: string | null;
|
|
75
|
+
device_key?: string | null;
|
|
76
|
+
session_count?: number | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The POST body sent to `POST /v1/questionnaires/{id}/responses` (mirrors the server
|
|
81
|
+
* contract). `opinion` is always present; `improve`/`suggestions` are null when empty.
|
|
82
|
+
*/
|
|
83
|
+
export interface QuestionnaireSubmission {
|
|
84
|
+
session_id: string | null;
|
|
85
|
+
answers: {
|
|
86
|
+
opinion: string;
|
|
87
|
+
improve: string | null;
|
|
88
|
+
suggestions: string | null;
|
|
89
|
+
};
|
|
90
|
+
meta: QuestionnaireMeta;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The GET /v1/questionnaires/decision response. `fire` gates showing; `questionnaire` is
|
|
95
|
+
* the definition to render when firing. Hard fail-open on the transport: an unreachable
|
|
96
|
+
* endpoint or a 404 resolves to null so the host simply never shows the gate.
|
|
97
|
+
*/
|
|
98
|
+
export interface QuestionnaireDecisionResponse {
|
|
99
|
+
fire: boolean;
|
|
100
|
+
reason?: string;
|
|
101
|
+
questionnaire?: QuestionnaireDefinition | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The analytics moments, mirrored on the coachmark callback convention (no PII, no free text). */
|
|
105
|
+
export type QuestionnaireGateEventName =
|
|
106
|
+
| "questionnaire_prompt_shown"
|
|
107
|
+
| "questionnaire_submitted"
|
|
108
|
+
| "questionnaire_dismissed";
|
|
109
|
+
|
|
110
|
+
/** One analytics moment. Carries the gate id only; answers ride ONLY in the POST body. */
|
|
111
|
+
export interface QuestionnaireGateEvent {
|
|
112
|
+
name: QuestionnaireGateEventName;
|
|
113
|
+
/** The gate id (`questionnaire.id`). */
|
|
114
|
+
id: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Where kit → server requests go (same tenant creds as the onboarding flow). */
|
|
118
|
+
export interface QuestionnaireTarget {
|
|
119
|
+
/** Base server URL (same as `WireOnboardingConfig.serverUrl`). */
|
|
120
|
+
serverUrl: string;
|
|
121
|
+
/** Tenant API key, sent as `Authorization: Bearer`. */
|
|
122
|
+
apiKey: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Props for the `QuestionnaireGate` popup component. */
|
|
126
|
+
export interface QuestionnaireGateProps {
|
|
127
|
+
/** The questionnaire to render (title + labels + id). */
|
|
128
|
+
questionnaire: QuestionnaireDefinition;
|
|
129
|
+
/** Where to POST the submission. Omit to skip the network (callbacks still fire). */
|
|
130
|
+
target?: QuestionnaireTarget;
|
|
131
|
+
/** The onboarding/session id correlated with the response, when known. */
|
|
132
|
+
sessionId?: string | null;
|
|
133
|
+
/** Small non-PII extras stored with the response (user_id, device_key, session_count). */
|
|
134
|
+
meta?: QuestionnaireMeta;
|
|
135
|
+
/** Fired at each analytics moment (host wires it to its own analytics). No PII. */
|
|
136
|
+
onEvent?: (event: QuestionnaireGateEvent) => void;
|
|
137
|
+
/** Called once the gate is resolved (submitted or dismissed). */
|
|
138
|
+
onResolved?: () => void;
|
|
139
|
+
/** Called when the gate first renders to the user (also fires `questionnaire_prompt_shown`). */
|
|
140
|
+
onShown?: () => void;
|
|
141
|
+
/** Partial theme merged over the active onboarding theme. */
|
|
142
|
+
theme?: Partial<import("../theme/types").OnboardingTheme>;
|
|
143
|
+
/** Show a Skip/Not now control. Default true (always skippable, never a dead end). */
|
|
144
|
+
dismissible?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Options for `useQuestionnaireGate` - the WHEN decision. */
|
|
148
|
+
export interface UseQuestionnaireGateOptions {
|
|
149
|
+
/** The questionnaire definition (id + local firing rules). */
|
|
150
|
+
config: QuestionnaireDefinition;
|
|
151
|
+
/** Server-provided decision (from GET /v1/questionnaires/decision). OVERRIDES local rules. */
|
|
152
|
+
decision?: QuestionnaireDecision;
|
|
153
|
+
/** Current tracked app-event count (host-owned), for the `minEvents` rule. */
|
|
154
|
+
events?: number;
|
|
155
|
+
/** Sync gate storage. Defaults to the storage injected by CoachmarkProvider. */
|
|
156
|
+
storage?: CoachmarkStorage | null;
|
|
157
|
+
/** Testing override; defaults to the global `isTestingCoachmark` flag. */
|
|
158
|
+
isTesting?: boolean;
|
|
159
|
+
/**
|
|
160
|
+
* OPTIONAL feature-flag wiring - the questionnaire MASTER switch. When
|
|
161
|
+
* `flags.questionnaire.enabled` is false, the gate NEVER fires (`visible` stays false,
|
|
162
|
+
* verdict reason `feature_disabled`), whatever the local rules or a server decision say -
|
|
163
|
+
* it composes as an extra AND on top of the decision seam. Provide pre-resolved `features`,
|
|
164
|
+
* OR a `featuresConfig` to lazily fetch once, OR mount a `WireFeaturesProvider` above
|
|
165
|
+
* (context is read automatically). Omit all three and the questionnaire stays on (fail-open).
|
|
166
|
+
*/
|
|
167
|
+
features?: WireFeatures;
|
|
168
|
+
/** Lazy-fetch config for the flags, used when `features` is absent and no provider is above. */
|
|
169
|
+
featuresConfig?: WireFeaturesConfig;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** What `useQuestionnaireGate` returns. */
|
|
173
|
+
export interface QuestionnaireGateController {
|
|
174
|
+
/** Whether the gate should be shown right now. */
|
|
175
|
+
visible: boolean;
|
|
176
|
+
/** The resolved verdict (local rules or the server override). For debugging/analytics. */
|
|
177
|
+
decision: QuestionnaireDecision;
|
|
178
|
+
/** Persist the last-shown timestamp. Call when the gate becomes visible. */
|
|
179
|
+
markShown: () => void;
|
|
180
|
+
/** Write the once-per-user seen gate. Call when the gate is resolved. */
|
|
181
|
+
markResolved: () => void;
|
|
182
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useQuestionnaireGate - owns WHEN the questionnaire 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_questionnaire_*` keys). The AI seam: pass a server-provided `decision` and it
|
|
7
|
+
* OVERRIDES the local rules (see decideQuestionnaire). The whole cost of being AI-ready is
|
|
8
|
+
* passing that object straight through - mirroring `useReviewGate` exactly.
|
|
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_questionnaire_<id>_seen`) always applies locally, so a server bug can never re-spam
|
|
13
|
+
* the popup. The global `isTestingCoachmark` flag force-shows the gate for QA replay, and the
|
|
14
|
+
* `questionnaire` master switch (a disabled tenant) wins over everything.
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
17
|
+
|
|
18
|
+
import { hasSeenGate, isCoachmarkTesting, markSeenGate } from "../coachmarks/runtime";
|
|
19
|
+
import { useResolvedFeatures } from "../features/WireFeaturesProvider";
|
|
20
|
+
import { sameDecision, shallowEqual } from "../reviews/equality";
|
|
21
|
+
import { decideQuestionnaire, evaluateGate, resolveRules } from "./decision";
|
|
22
|
+
import {
|
|
23
|
+
questionnaireLastShownKey,
|
|
24
|
+
questionnaireSeenKey,
|
|
25
|
+
questionnaireSessionsKey,
|
|
26
|
+
readInt,
|
|
27
|
+
resolveStorage,
|
|
28
|
+
writeInt,
|
|
29
|
+
} from "./runtime";
|
|
30
|
+
import type {
|
|
31
|
+
QuestionnaireDecision,
|
|
32
|
+
QuestionnaireGateController,
|
|
33
|
+
UseQuestionnaireGateOptions,
|
|
34
|
+
} from "./types";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Hold a stable reference for a value while its CONTENT is unchanged. Integrators pass inline
|
|
38
|
+
* `config` / `decision` objects (idiomatic React) whose identity flips every render; feeding
|
|
39
|
+
* those straight into the deps below would recompute the verdict - and mint a fresh verdict
|
|
40
|
+
* object - on every render, which loops any host that lists the returned verdict in its own
|
|
41
|
+
* effect. Comparing by value here keeps the reference (and everything derived from it) stable.
|
|
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 useQuestionnaireGate = ({
|
|
50
|
+
config: configProp,
|
|
51
|
+
decision: decisionProp,
|
|
52
|
+
events = 0,
|
|
53
|
+
storage,
|
|
54
|
+
isTesting,
|
|
55
|
+
features,
|
|
56
|
+
featuresConfig,
|
|
57
|
+
}: UseQuestionnaireGateOptions): QuestionnaireGateController => {
|
|
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(decisionProp, (a, b) => sameDecision(a, b));
|
|
61
|
+
|
|
62
|
+
// The questionnaire MASTER switch. Disabled → the gate never fires, composing as an extra
|
|
63
|
+
// AND over the local rules + the server decision seam below (fail-open: defaults to enabled).
|
|
64
|
+
const featureEnabled = useResolvedFeatures({ flags: features, config: featuresConfig })
|
|
65
|
+
.questionnaire.enabled;
|
|
66
|
+
|
|
67
|
+
const testing = isTesting ?? isCoachmarkTesting();
|
|
68
|
+
const seenKey = questionnaireSeenKey(
|
|
69
|
+
config.id,
|
|
70
|
+
config.oncePerVersion === false ? undefined : config.appVersion,
|
|
71
|
+
);
|
|
72
|
+
const lastKey = questionnaireLastShownKey(config.id);
|
|
73
|
+
const sessionsKey = questionnaireSessionsKey(config.id);
|
|
74
|
+
|
|
75
|
+
// Read (and bump) the session counter ONCE per mount: this mount is a new session.
|
|
76
|
+
const sessions = useState(() => {
|
|
77
|
+
const store = resolveStorage(storage);
|
|
78
|
+
const next = readInt(store, sessionsKey) + 1;
|
|
79
|
+
writeInt(store, sessionsKey, next);
|
|
80
|
+
return next;
|
|
81
|
+
})[0];
|
|
82
|
+
|
|
83
|
+
// Gate the local rules behind an optional client-side timeout, so a reachable server gets a
|
|
84
|
+
// window to answer first. A present `decision` bypasses the wait entirely.
|
|
85
|
+
const hasServerDecision = decision != null;
|
|
86
|
+
const timeoutMs = config.timeoutFallbackMs;
|
|
87
|
+
const waits = !hasServerDecision && timeoutMs != null && timeoutMs > 0;
|
|
88
|
+
const [elapsed, setElapsed] = useState(!waits);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!waits) {
|
|
91
|
+
setElapsed(true);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const timer = setTimeout(() => setElapsed(true), timeoutMs as number);
|
|
95
|
+
return () => clearTimeout(timer);
|
|
96
|
+
}, [waits, timeoutMs]);
|
|
97
|
+
|
|
98
|
+
const { visible, verdict } = useMemo(() => {
|
|
99
|
+
// Master kill switch wins over everything, including QA replay: a tenant that turned the
|
|
100
|
+
// questionnaire OFF must never see it, whatever the rules, the server decision, or testing say.
|
|
101
|
+
if (!featureEnabled)
|
|
102
|
+
return {
|
|
103
|
+
visible: false,
|
|
104
|
+
verdict: { fire: false, reason: "feature_disabled" } as QuestionnaireDecision,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// QA replay: force-show, bypassing rules + gates (safe: only via __DEV__ && bool).
|
|
108
|
+
if (testing)
|
|
109
|
+
return {
|
|
110
|
+
visible: true,
|
|
111
|
+
verdict: { fire: true, reason: "testing" } as QuestionnaireDecision,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const store = resolveStorage(storage);
|
|
115
|
+
const local = evaluateGate(resolveRules(config), {
|
|
116
|
+
sessions,
|
|
117
|
+
events,
|
|
118
|
+
lastShownAt: readInt(store, lastKey) || null,
|
|
119
|
+
seen: hasSeenGate(seenKey, storage, isTesting),
|
|
120
|
+
now: Date.now(),
|
|
121
|
+
});
|
|
122
|
+
const resolved = decideQuestionnaire(local, decision);
|
|
123
|
+
// (3) the once-gate always wins locally, even over a server "fire".
|
|
124
|
+
const alreadySeen = hasSeenGate(seenKey, storage, isTesting);
|
|
125
|
+
const ready = hasServerDecision || elapsed;
|
|
126
|
+
return { visible: ready && resolved.fire && !alreadySeen, verdict: resolved };
|
|
127
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
128
|
+
}, [
|
|
129
|
+
featureEnabled,
|
|
130
|
+
testing,
|
|
131
|
+
config,
|
|
132
|
+
decision,
|
|
133
|
+
events,
|
|
134
|
+
sessions,
|
|
135
|
+
elapsed,
|
|
136
|
+
hasServerDecision,
|
|
137
|
+
seenKey,
|
|
138
|
+
lastKey,
|
|
139
|
+
storage,
|
|
140
|
+
isTesting,
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
// Stable callbacks + a stable controller object, so a host can safely list any of them in its
|
|
144
|
+
// own effect deps without the effect re-firing every render.
|
|
145
|
+
const shownRef = useRef(false);
|
|
146
|
+
const markShown = useCallback(() => {
|
|
147
|
+
if (shownRef.current) return;
|
|
148
|
+
shownRef.current = true;
|
|
149
|
+
writeInt(resolveStorage(storage), lastKey, Date.now());
|
|
150
|
+
}, [storage, lastKey]);
|
|
151
|
+
const markResolved = useCallback(() => {
|
|
152
|
+
markSeenGate(seenKey, storage, isTesting);
|
|
153
|
+
}, [seenKey, storage, isTesting]);
|
|
154
|
+
|
|
155
|
+
return useMemo<QuestionnaireGateController>(
|
|
156
|
+
() => ({ visible, decision: verdict, markShown, markResolved }),
|
|
157
|
+
[visible, verdict, markShown, markResolved],
|
|
158
|
+
);
|
|
159
|
+
};
|