@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wireai-onboarding — premium, themable drop-in onboarding kit on top of the
|
|
3
|
+
* open-source wireai-rn SDK. Public surface.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ─── Host ────────────────────────────────────────────────────────────────────
|
|
7
|
+
export { WireOnboarding } from "./WireOnboarding";
|
|
8
|
+
/** Dev/QA: an on-demand modal trigger to run the flow without a new account. */
|
|
9
|
+
export { DemoOnboarding } from "./DemoOnboarding";
|
|
10
|
+
export type { DemoOnboardingProps } from "./DemoOnboarding";
|
|
11
|
+
|
|
12
|
+
// ─── Theme ───────────────────────────────────────────────────────────────────
|
|
13
|
+
export { OnboardingThemeProvider, useOnboardingTheme } from "./theme/ThemeContext";
|
|
14
|
+
export { defaultOnboardingTheme, mergeTheme } from "./theme/defaultTheme";
|
|
15
|
+
export { themeFromBrand } from "./theme/themeFromBrand";
|
|
16
|
+
export type { ThemeFromBrandInput } from "./theme/themeFromBrand";
|
|
17
|
+
export type {
|
|
18
|
+
OnboardingTheme,
|
|
19
|
+
OnboardingColors,
|
|
20
|
+
OnboardingFonts,
|
|
21
|
+
OnboardingRadius,
|
|
22
|
+
OnboardingSpacing,
|
|
23
|
+
OnboardingButtonStyle,
|
|
24
|
+
} from "./theme/types";
|
|
25
|
+
|
|
26
|
+
// ─── Shell components (compose your own flow if needed) ───────────────────────
|
|
27
|
+
export { OnboardingScaffold } from "./components/OnboardingScaffold";
|
|
28
|
+
export { StepProgress } from "./components/StepProgress";
|
|
29
|
+
export { LoadingBlock } from "./components/LoadingBlock";
|
|
30
|
+
export { LoadingScreen } from "./components/LoadingScreen";
|
|
31
|
+
export { AnimatedSparkle } from "./components/AnimatedSparkle";
|
|
32
|
+
export { ErrorBlock } from "./components/ErrorBlock";
|
|
33
|
+
/** @deprecated No longer used internally — the terminal screen is CompletionView. */
|
|
34
|
+
export { DoneBlock } from "./components/DoneBlock";
|
|
35
|
+
export { CompletionView } from "./components/CompletionView";
|
|
36
|
+
export { IllustrationProvider, useIllustration } from "./components/Illustration";
|
|
37
|
+
export type { IllustrationRegistry } from "./components/Illustration";
|
|
38
|
+
export { defaultIllustrations } from "./illustrations/defaultIllustrations";
|
|
39
|
+
export { Button as OnboardingButton } from "./components/Button";
|
|
40
|
+
export { CardHandoff } from "./components/CardHandoff";
|
|
41
|
+
export type { CardHandoffProps, CardHandoffVariant } from "./components/CardHandoff";
|
|
42
|
+
/** Content-agnostic centered-popup shell (the reviews `ReviewModal` re-exports this). */
|
|
43
|
+
export { CenteredModal } from "./components/CenteredModal";
|
|
44
|
+
export type { CenteredModalHandle, CenteredModalProps } from "./components/CenteredModal";
|
|
45
|
+
|
|
46
|
+
// ─── Motion (the design-spec constants + the reduce-motion gate) ──────────────
|
|
47
|
+
export { useReducedMotion } from "./motion/useReducedMotion";
|
|
48
|
+
export * as motionSpec from "./motion/motionSpec";
|
|
49
|
+
|
|
50
|
+
// ─── Themed cards + the registered set ────────────────────────────────────────
|
|
51
|
+
export {
|
|
52
|
+
ChipSelectCard,
|
|
53
|
+
TextInputCard,
|
|
54
|
+
SelectionCard,
|
|
55
|
+
StatusCard,
|
|
56
|
+
NumberStepperCard,
|
|
57
|
+
InterstitialCard,
|
|
58
|
+
onboardingComponents,
|
|
59
|
+
} from "./cards";
|
|
60
|
+
|
|
61
|
+
// ─── Flow internals (advanced) ────────────────────────────────────────────────
|
|
62
|
+
export { OnboardingFlow } from "./OnboardingFlow";
|
|
63
|
+
export { deriveAnswers } from "./utils/deriveAnswers";
|
|
64
|
+
export { readProgress } from "./utils/readProgress";
|
|
65
|
+
|
|
66
|
+
// ─── Feature controls (per-module dashboard kill switches; hard fail-open) ────
|
|
67
|
+
export {
|
|
68
|
+
fetchWireFeatures,
|
|
69
|
+
useWireFeatures,
|
|
70
|
+
WireFeaturesProvider,
|
|
71
|
+
useWireFeaturesContext,
|
|
72
|
+
useResolvedFeatures,
|
|
73
|
+
defaultWireFeatures,
|
|
74
|
+
parseWireFeatures,
|
|
75
|
+
featuresEqual,
|
|
76
|
+
featuresCacheKey,
|
|
77
|
+
readCachedFeatures,
|
|
78
|
+
writeCachedFeatures,
|
|
79
|
+
isFeaturesFresh,
|
|
80
|
+
DEFAULT_FEATURES_TTL_MS,
|
|
81
|
+
} from "./features";
|
|
82
|
+
export type {
|
|
83
|
+
WireFeatures,
|
|
84
|
+
WireFeaturesConfig,
|
|
85
|
+
OnboardingMode,
|
|
86
|
+
WireFeaturesProviderProps,
|
|
87
|
+
ResolveFeaturesOptions,
|
|
88
|
+
CachedFeatures,
|
|
89
|
+
} from "./features";
|
|
90
|
+
|
|
91
|
+
// ─── Config + env helpers ─────────────────────────────────────────────────────
|
|
92
|
+
export { wireConfigFromEnv } from "./config/wireConfigFromEnv";
|
|
93
|
+
export type { WireConfigOverrides } from "./config/wireConfigFromEnv";
|
|
94
|
+
export { isOnboardingEnabled } from "./config/onboardingFlag";
|
|
95
|
+
export type { OnboardingFlagOptions } from "./config/onboardingFlag";
|
|
96
|
+
|
|
97
|
+
// ─── Analytics (canonical funnel names) ───────────────────────────────────────
|
|
98
|
+
export { WIRE_ONBOARDING_EVENTS, toAnalyticsEvent } from "./analytics/analyticsEvent";
|
|
99
|
+
export type { WireOnboardingEventName, AnalyticsEvent } from "./analytics/analyticsEvent";
|
|
100
|
+
|
|
101
|
+
// ─── Client event reporting (device-only funnel events → /v1/events) ──────────
|
|
102
|
+
export {
|
|
103
|
+
reportClientEvent,
|
|
104
|
+
reportClientEvents,
|
|
105
|
+
makeSessionId,
|
|
106
|
+
} from "./analytics/reportClientEvent";
|
|
107
|
+
export type {
|
|
108
|
+
ClientEvent,
|
|
109
|
+
ClientEventType,
|
|
110
|
+
ClientEventTarget,
|
|
111
|
+
} from "./analytics/reportClientEvent";
|
|
112
|
+
|
|
113
|
+
// ─── Attribution forwarding (→ config.metadata) ───────────────────────────────
|
|
114
|
+
export { attributionMetadata } from "./attribution/attribution";
|
|
115
|
+
export type { OnboardingAttribution } from "./attribution/attribution";
|
|
116
|
+
|
|
117
|
+
// ─── Device context (privacy-label-neutral, dependency-free) ──────────────────
|
|
118
|
+
export { collectDeviceContext } from "./device/deviceContext";
|
|
119
|
+
export type { DeviceContext, DeviceFormFactor } from "./device/deviceContext";
|
|
120
|
+
|
|
121
|
+
// ─── User identity (opaque pseudonymous id; late binding, dependency-free) ────
|
|
122
|
+
export { identifyOnboarding, sanitizeUserId, USER_ID_MAX_LENGTH } from "./identity/userIdentity";
|
|
123
|
+
export type { IdentifyOnboardingOptions } from "./identity/userIdentity";
|
|
124
|
+
|
|
125
|
+
// ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
|
|
126
|
+
export {
|
|
127
|
+
reportSessionStart,
|
|
128
|
+
resetSessionStartGuard,
|
|
129
|
+
SESSION_STARTED_EVENT,
|
|
130
|
+
useSessionStart,
|
|
131
|
+
BACKGROUND_SESSION_MS,
|
|
132
|
+
} from "./session-analytics";
|
|
133
|
+
export type {
|
|
134
|
+
ReportSessionStartOptions,
|
|
135
|
+
SessionStartConfig,
|
|
136
|
+
UseSessionStartOptions,
|
|
137
|
+
} from "./session-analytics";
|
|
138
|
+
|
|
139
|
+
// ─── Session persistence (host-injected storage; see the `storage` prop) ──────
|
|
140
|
+
export {
|
|
141
|
+
loadPersistedSession,
|
|
142
|
+
peekPersistedSession,
|
|
143
|
+
savePersistedSession,
|
|
144
|
+
clearPersistedSession,
|
|
145
|
+
sessionStorageKey,
|
|
146
|
+
DEFAULT_SESSION_TTL_MS,
|
|
147
|
+
} from "./session/persistedSession";
|
|
148
|
+
export type { WireOnboardingStorage, LoadedSession } from "./session/persistedSession";
|
|
149
|
+
|
|
150
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
151
|
+
export type {
|
|
152
|
+
WireOnboardingProps,
|
|
153
|
+
WireOnboardingConfig,
|
|
154
|
+
OnboardingResult,
|
|
155
|
+
OnboardingEvent,
|
|
156
|
+
OnboardingCopy,
|
|
157
|
+
StepValidator,
|
|
158
|
+
OnboardingProgress,
|
|
159
|
+
} from "./types";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* centeredModalMotion - the pure entrance spec for the content-agnostic CenteredModal shell.
|
|
3
|
+
*
|
|
4
|
+
* WHY split out: the shell UI (CenteredModal) is RN and cannot run under the kit's node:test
|
|
5
|
+
* runner, but the entrance math is pure and worth pinning. Keep the animation spec here so both
|
|
6
|
+
* the component and the tests read one source, and so any module (reviews, questionnaire) can
|
|
7
|
+
* reuse the shell without depending on another feature module.
|
|
8
|
+
*
|
|
9
|
+
* The centered popup uses RN's built-in `Animated` (native driver), the same idiom as
|
|
10
|
+
* CompletionView/CardHandoff, so the shell stays dependency-free (no reanimated).
|
|
11
|
+
*
|
|
12
|
+
* NOTE: `reviews/presentation.ts` re-exports these under their original names
|
|
13
|
+
* (`REVIEW_MODAL_ANIM`, `entranceFrame`) for back-compat with the reviews public API.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The entrance spec for the centered popup. Values chosen to match the boilerplate's
|
|
18
|
+
* `animated-modal` feel (a springy zoom-in) with the kit's own native-driver idiom:
|
|
19
|
+
* • backdrop fades 0 -> `backdropOpacity`
|
|
20
|
+
* • card springs from `scaleFrom` -> 1 and fades 0 -> 1
|
|
21
|
+
*/
|
|
22
|
+
export const CENTERED_MODAL_ANIM = {
|
|
23
|
+
/** Spring config for the card scale/opacity entrance (mirrors CompletionView's pop). */
|
|
24
|
+
enterSpring: { friction: 7, tension: 80 },
|
|
25
|
+
/** Exit timing in ms (fade + shrink back out before unmount). */
|
|
26
|
+
exitMs: 160,
|
|
27
|
+
/** Card scale at rest (progress 0); grows to 1 at progress 1. */
|
|
28
|
+
scaleFrom: 0.9,
|
|
29
|
+
/** Peak dim of the backdrop at progress 1. */
|
|
30
|
+
backdropOpacity: 0.55,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
/** A single frame of the entrance, for a progress value in [0..1] (clamped). Pure, testable. */
|
|
34
|
+
export const centeredModalFrame = (
|
|
35
|
+
progress: number,
|
|
36
|
+
backdropMax: number = CENTERED_MODAL_ANIM.backdropOpacity,
|
|
37
|
+
): { cardOpacity: number; cardScale: number; backdropOpacity: number } => {
|
|
38
|
+
const p = progress < 0 ? 0 : progress > 1 ? 1 : progress;
|
|
39
|
+
const { scaleFrom } = CENTERED_MODAL_ANIM;
|
|
40
|
+
return {
|
|
41
|
+
cardOpacity: p,
|
|
42
|
+
cardScale: scaleFrom + (1 - scaleFrom) * p,
|
|
43
|
+
backdropOpacity: backdropMax * p,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* motionSpec — the single source of truth for every animation value in the kit,
|
|
3
|
+
* extracted 1:1 from the Wire Activation card-interactions design canvas
|
|
4
|
+
* (see design-refs/EXTRACTED-SPEC.md for the row-by-row mapping).
|
|
5
|
+
*
|
|
6
|
+
* Pure constants + pure helpers only — NO react-native import — so the spec is
|
|
7
|
+
* unit-testable under node:test and components can't drift from the design
|
|
8
|
+
* without a red test.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The design system's "ease-wire" curve: cubic-bezier(0.16, 1, 0.3, 1). */
|
|
12
|
+
export const WIRE_BEZIER = [0.16, 1, 0.3, 1] as const;
|
|
13
|
+
|
|
14
|
+
/** Design-system duration tokens (colors_and_type.css --duration-*). */
|
|
15
|
+
export const DURATION_FAST_MS = 150;
|
|
16
|
+
export const DURATION_BASE_MS = 300;
|
|
17
|
+
export const DURATION_SLOW_MS = 500;
|
|
18
|
+
|
|
19
|
+
/** Reduce-motion fallback: a short fade to the final frame, never a frozen mid-state. */
|
|
20
|
+
export const REDUCED_FADE_MS = 120;
|
|
21
|
+
|
|
22
|
+
// ── Press feedback (spec rows 1, 4, 20) ─────────────────────────────────────
|
|
23
|
+
export const ROW_PRESS_SCALE = 0.975;
|
|
24
|
+
export const CHIP_PRESS_SCALE = 0.94;
|
|
25
|
+
export const BUTTON_PRESS_SCALE = 0.975;
|
|
26
|
+
/** Reduce-motion press feedback: opacity dip instead of scale. */
|
|
27
|
+
export const REDUCED_PRESS_OPACITY = 0.85;
|
|
28
|
+
export const PRESS_SPRING = { friction: 8, tension: 220 } as const;
|
|
29
|
+
export const BUTTON_PRESS_SPRING = { friction: 8, tension: 240 } as const;
|
|
30
|
+
|
|
31
|
+
// ── Selection state changes (spec rows 2, 3, 5, 6) ──────────────────────────
|
|
32
|
+
export const SELECT_FILL_MS = 180;
|
|
33
|
+
export const CHIP_FILL_MS = 160;
|
|
34
|
+
export const CHECK_SPRING = { friction: 6, tension: 180 } as const;
|
|
35
|
+
export const CHECK_SCALE_FROM = 0.6;
|
|
36
|
+
|
|
37
|
+
// ── Card-to-card handoff (spec rows 7-10) ───────────────────────────────────
|
|
38
|
+
export const CARD_ENTER_MS = 220;
|
|
39
|
+
export const CARD_EXIT_MS = 180;
|
|
40
|
+
/** The exit starts this long BEFORE the enter (enter is delayed by this). */
|
|
41
|
+
export const CARD_EXIT_LEAD_MS = 80;
|
|
42
|
+
export const CARD_SLIDE_PX = 26;
|
|
43
|
+
export const CARD_ENTER_SCALE_FROM = 0.96;
|
|
44
|
+
/** The interstitial keeps the shell's springier value-beat (AnimatedCard idiom). */
|
|
45
|
+
export const CARD_SPRING = { damping: 14, stiffness: 180, mass: 1 } as const;
|
|
46
|
+
/** Loader-to-card handoff: the loader just fades out. */
|
|
47
|
+
export const LOADER_HANDOFF_FADE_MS = 120;
|
|
48
|
+
|
|
49
|
+
// ── InterstitialCard reveal (spec rows 11-13) ───────────────────────────────
|
|
50
|
+
export const INTERSTITIAL_HEAD_MS = 500;
|
|
51
|
+
export const INTERSTITIAL_HEAD_STAGGER_MS = 100;
|
|
52
|
+
export const INTERSTITIAL_ITEM_MS = 300;
|
|
53
|
+
export const INTERSTITIAL_ITEM_STAGGER_MS = 90;
|
|
54
|
+
export const INTERSTITIAL_ITEM_BASE_DELAY_MS = 200;
|
|
55
|
+
export const INTERSTITIAL_CHECK_DELAY_MS = 200;
|
|
56
|
+
export const INTERSTITIAL_CHECK_SCALE_FROM = 0.4;
|
|
57
|
+
|
|
58
|
+
/** Delay before work item `index` begins its reveal (row lands after the head). */
|
|
59
|
+
export const interstitialItemDelay = (index: number): number =>
|
|
60
|
+
INTERSTITIAL_ITEM_BASE_DELAY_MS + index * INTERSTITIAL_ITEM_STAGGER_MS;
|
|
61
|
+
|
|
62
|
+
/** Delay before work item `index`'s check pops (200ms after its row starts). */
|
|
63
|
+
export const interstitialCheckDelay = (index: number): number =>
|
|
64
|
+
interstitialItemDelay(index) + INTERSTITIAL_CHECK_DELAY_MS;
|
|
65
|
+
|
|
66
|
+
// ── Status / completion payoff (spec rows 14-16) ────────────────────────────
|
|
67
|
+
export const STATUS_POP_SPRING = { friction: 6, tension: 200 } as const;
|
|
68
|
+
export const STATUS_POP_SCALE_FROM = 0.4;
|
|
69
|
+
export const STATUS_BURST_MS = 500;
|
|
70
|
+
export const STATUS_BURST_SCALE_FROM = 0.4;
|
|
71
|
+
export const STATUS_BURST_SCALE_TO = 1.5;
|
|
72
|
+
export const STATUS_BURST_OPACITY_FROM = 0.5;
|
|
73
|
+
export const STATUS_COPY_MS = 300;
|
|
74
|
+
export const STATUS_COPY_STAGGER_MS = 80;
|
|
75
|
+
/** Copy starts settling once the badge pop is visibly underway. */
|
|
76
|
+
export const STATUS_COPY_BASE_DELAY_MS = 200;
|
|
77
|
+
|
|
78
|
+
/** Delay before payoff copy line `index` fades up. */
|
|
79
|
+
export const statusCopyDelay = (index: number): number =>
|
|
80
|
+
STATUS_COPY_BASE_DELAY_MS + index * STATUS_COPY_STAGGER_MS;
|
|
81
|
+
|
|
82
|
+
// ── Loading treatment (spec rows 17-19) ─────────────────────────────────────
|
|
83
|
+
export const LOADER_PULSE_MS = 1600;
|
|
84
|
+
export const LOADER_PULSE_MIN_OPACITY = 0.35;
|
|
85
|
+
export const LOADER_ORBIT_MS = 2400;
|
|
86
|
+
/** Skeleton lines appear only when the fetch runs past this. */
|
|
87
|
+
export const SKELETON_DELAY_MS = 300;
|
|
88
|
+
export const SKELETON_PULSE_MS = 1500;
|
|
89
|
+
export const SKELETON_MIN_OPACITY = 0.45;
|
|
90
|
+
|
|
91
|
+
// ── Progress bar (spec row 21) ──────────────────────────────────────────────
|
|
92
|
+
export const PROGRESS_MS = 400;
|
|
93
|
+
|
|
94
|
+
// ── Dev/QA duration scale (slow-mo for judging motion) ──────────────────────
|
|
95
|
+
// A module-level multiplier applied to every timing DURATION (and stagger
|
|
96
|
+
// delay) at animation start. 1 = design speed; 2 = half-speed slow-mo. Springs
|
|
97
|
+
// are physics-based (friction/tension) and do NOT scale. This is a dev/QA tool
|
|
98
|
+
// (the playground's tweak panel drives it) — production code never calls the
|
|
99
|
+
// setter, so the default 1 keeps shipped motion exactly on spec.
|
|
100
|
+
|
|
101
|
+
let durationScale = 1;
|
|
102
|
+
|
|
103
|
+
/** Set the global duration multiplier (clamped to 0.1-10; invalid resets to 1). */
|
|
104
|
+
export const setMotionDurationScale = (scale: number): void => {
|
|
105
|
+
durationScale = Number.isFinite(scale) && scale > 0 ? Math.min(10, Math.max(0.1, scale)) : 1;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const getMotionDurationScale = (): number => durationScale;
|
|
109
|
+
|
|
110
|
+
/** Apply the current duration scale to a spec duration/delay (rounded ms). */
|
|
111
|
+
export const scaledMs = (ms: number): number => Math.round(ms * durationScale);
|
|
112
|
+
|
|
113
|
+
// ── Reduce-motion helpers (pure, shared by every animated component) ────────
|
|
114
|
+
|
|
115
|
+
export type EntranceSpec = {
|
|
116
|
+
/** Duration to animate with (falls back to the short fade under reduce motion). */
|
|
117
|
+
duration: number;
|
|
118
|
+
/** Whether transforms (translate/scale) may move; false = opacity-only fade. */
|
|
119
|
+
useTransform: boolean;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Entrances: full motion normally; a 120ms opacity-only fade under reduce motion. */
|
|
123
|
+
export const entranceSpec = (reduced: boolean, durationMs: number): EntranceSpec =>
|
|
124
|
+
reduced
|
|
125
|
+
? { duration: REDUCED_FADE_MS, useTransform: false }
|
|
126
|
+
: { duration: durationMs, useTransform: true };
|
|
127
|
+
|
|
128
|
+
/** State changes (select fills etc.): snap instantly under reduce motion. */
|
|
129
|
+
export const stateChangeDuration = (reduced: boolean, durationMs: number): number =>
|
|
130
|
+
reduced ? 0 : durationMs;
|
|
131
|
+
|
|
132
|
+
/** Looping/ambient animations run only when motion is allowed. */
|
|
133
|
+
export const loopEnabled = (reduced: boolean): boolean => !reduced;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useReducedMotion — the kit's ONE reduce-motion gate. Reads the OS setting via
|
|
3
|
+
* AccessibilityInfo and stays subscribed to `reduceMotionChanged`, so every
|
|
4
|
+
* animated component lands on its final frame (or a ~120ms fade) the moment the
|
|
5
|
+
* user flips the setting — no restart needed.
|
|
6
|
+
*
|
|
7
|
+
* This closes the gap documented in ai_rules/rules/performance.md §3: every
|
|
8
|
+
* animation in the kit must consume this hook (usually through the pure helpers
|
|
9
|
+
* in motionSpec.ts: `entranceSpec` / `stateChangeDuration` / `loopEnabled`).
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useState } from "react";
|
|
12
|
+
import { AccessibilityInfo } from "react-native";
|
|
13
|
+
|
|
14
|
+
// Last-known value, module-level: cards remount every turn (keyed on message id),
|
|
15
|
+
// and a `useState(false)` default would flash one animated frame per turn for a
|
|
16
|
+
// reduce-motion user before the async read resolves.
|
|
17
|
+
let lastKnown = false;
|
|
18
|
+
|
|
19
|
+
export const useReducedMotion = (): boolean => {
|
|
20
|
+
const [reduced, setReduced] = useState(lastKnown);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let mounted = true;
|
|
24
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
25
|
+
.then((value) => {
|
|
26
|
+
lastKnown = value;
|
|
27
|
+
if (mounted) setReduced(value);
|
|
28
|
+
})
|
|
29
|
+
.catch(() => {
|
|
30
|
+
// Unavailable (e.g. some test envs): keep the last-known value.
|
|
31
|
+
});
|
|
32
|
+
const sub = AccessibilityInfo.addEventListener("reduceMotionChanged", (value) => {
|
|
33
|
+
lastKnown = value;
|
|
34
|
+
setReduced(value);
|
|
35
|
+
});
|
|
36
|
+
return () => {
|
|
37
|
+
mounted = false;
|
|
38
|
+
sub?.remove();
|
|
39
|
+
};
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
return reduced;
|
|
43
|
+
};
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QuestionnaireGate - the skippable pre-onboarding questionnaire popup.
|
|
3
|
+
*
|
|
4
|
+
* A centered popup (over the shared `CenteredModal` shell) with three inputs:
|
|
5
|
+
* • a MANDATORY multiline "what do you think about the app" box (~4 lines), and
|
|
6
|
+
* • two OPTIONAL smaller boxes ("help us improve" + "suggestions").
|
|
7
|
+
* Send is disabled ONLY until the opinion box has text; the two optional boxes never gate it.
|
|
8
|
+
* The gate is ALWAYS skippable (a "Not now"/"Skip" link plays the popup exit), so it is never
|
|
9
|
+
* a dead end. On send it builds the contract body and fire-and-forgets it to the Wire server,
|
|
10
|
+
* then shows a warm thank-you until the user taps the backdrop to close.
|
|
11
|
+
*
|
|
12
|
+
* The form NEVER asks for identity (no name / email / contact field): the host already
|
|
13
|
+
* identifies the user via `sessionId` + `meta` (user_id, device_key, session_count), which is
|
|
14
|
+
* all the POST body carries alongside the answers. Every visual value comes from the
|
|
15
|
+
* OnboardingTheme; analytics are callback-based (no PII - answers go ONLY in the POST body).
|
|
16
|
+
*/
|
|
17
|
+
import React, { useCallback, useMemo, useState } from "react";
|
|
18
|
+
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
|
19
|
+
|
|
20
|
+
import { Button } from "../components/Button";
|
|
21
|
+
import { CenteredModal, type CenteredModalHandle } from "../components/CenteredModal";
|
|
22
|
+
import { feedbackInputHeights } from "../reviews/feedbackForm";
|
|
23
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
24
|
+
import type { OnboardingTheme } from "../theme/types";
|
|
25
|
+
import { bodyStyle, captionStyle, headingStyle } from "../theme/typography";
|
|
26
|
+
import { buildQuestionnaireSubmission } from "./decision";
|
|
27
|
+
import { submitQuestionnaireResponse } from "./transport";
|
|
28
|
+
import type { QuestionnaireGateProps } from "./types";
|
|
29
|
+
|
|
30
|
+
const mergeThemeOver = (
|
|
31
|
+
base: OnboardingTheme,
|
|
32
|
+
override?: Partial<OnboardingTheme>,
|
|
33
|
+
): OnboardingTheme => {
|
|
34
|
+
if (!override) return base;
|
|
35
|
+
return {
|
|
36
|
+
colors: { ...base.colors, ...override.colors },
|
|
37
|
+
fonts: { ...base.fonts, ...override.fonts },
|
|
38
|
+
radius: { ...base.radius, ...override.radius },
|
|
39
|
+
spacing: { ...base.spacing, ...override.spacing },
|
|
40
|
+
button: { ...base.button, ...override.button },
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type Phase = "form" | "thanks";
|
|
45
|
+
|
|
46
|
+
export const QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
47
|
+
questionnaire,
|
|
48
|
+
target,
|
|
49
|
+
sessionId,
|
|
50
|
+
meta,
|
|
51
|
+
onEvent,
|
|
52
|
+
onResolved,
|
|
53
|
+
onShown,
|
|
54
|
+
theme: themeOverride,
|
|
55
|
+
dismissible = true,
|
|
56
|
+
}) => {
|
|
57
|
+
const active = useOnboardingTheme();
|
|
58
|
+
const t = useMemo(() => mergeThemeOver(active, themeOverride), [active, themeOverride]);
|
|
59
|
+
const modalRef = React.useRef<CenteredModalHandle>(null);
|
|
60
|
+
|
|
61
|
+
const id = questionnaire.id;
|
|
62
|
+
const [phase, setPhase] = useState<Phase>("form");
|
|
63
|
+
const [opinion, setOpinion] = useState("");
|
|
64
|
+
const [improve, setImprove] = useState("");
|
|
65
|
+
const [suggestions, setSuggestions] = useState("");
|
|
66
|
+
|
|
67
|
+
// Fire questionnaire_prompt_shown exactly ONCE per mount. The callbacks are read through refs
|
|
68
|
+
// (not effect deps) so an inline `onShown`/`onEvent` - recreated every render, as real hosts
|
|
69
|
+
// idiomatically pass - never re-runs the effect. Empty deps + the shownRef guard make this a
|
|
70
|
+
// true fire-once, immune to prop-identity churn.
|
|
71
|
+
const shownRef = React.useRef(false);
|
|
72
|
+
const onShownRef = React.useRef(onShown);
|
|
73
|
+
const onEventRef = React.useRef(onEvent);
|
|
74
|
+
onShownRef.current = onShown;
|
|
75
|
+
onEventRef.current = onEvent;
|
|
76
|
+
React.useEffect(() => {
|
|
77
|
+
if (shownRef.current) return;
|
|
78
|
+
shownRef.current = true;
|
|
79
|
+
onShownRef.current?.();
|
|
80
|
+
onEventRef.current?.({ name: "questionnaire_prompt_shown", id });
|
|
81
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const resolve = useCallback(() => {
|
|
85
|
+
onResolved?.();
|
|
86
|
+
}, [onResolved]);
|
|
87
|
+
|
|
88
|
+
// The opinion box is the ONLY gate on Send. The two optional boxes never disable it.
|
|
89
|
+
const canSend = opinion.trim().length > 0;
|
|
90
|
+
|
|
91
|
+
const send = useCallback(() => {
|
|
92
|
+
if (!canSend) return;
|
|
93
|
+
// Identity is NEVER collected here - the host already sends user_id / device_key / session
|
|
94
|
+
// via `sessionId` + `meta`. We POST only the three answers.
|
|
95
|
+
const submission = buildQuestionnaireSubmission({
|
|
96
|
+
answers: { opinion, improve, suggestions },
|
|
97
|
+
sessionId,
|
|
98
|
+
meta,
|
|
99
|
+
});
|
|
100
|
+
submitQuestionnaireResponse(target, id, submission);
|
|
101
|
+
onEvent?.({ name: "questionnaire_submitted", id });
|
|
102
|
+
setPhase("thanks");
|
|
103
|
+
resolve();
|
|
104
|
+
}, [canSend, opinion, improve, suggestions, sessionId, meta, target, id, onEvent, resolve]);
|
|
105
|
+
|
|
106
|
+
// The in-card "Not now"/"Skip" link plays the popup's exit animation (which resolves on
|
|
107
|
+
// completion, the same path a backdrop tap takes). Emit the dismissed moment first.
|
|
108
|
+
const handleDismissTap = useCallback(() => {
|
|
109
|
+
onEvent?.({ name: "questionnaire_dismissed", id });
|
|
110
|
+
modalRef.current?.close();
|
|
111
|
+
}, [onEvent, id]);
|
|
112
|
+
|
|
113
|
+
const title = questionnaire.title ?? "How's it going so far?";
|
|
114
|
+
const opinionLabel = questionnaire.opinion_label ?? "What do you think about the app?";
|
|
115
|
+
const improveLabel = questionnaire.improve_label ?? "How can we improve? (optional)";
|
|
116
|
+
const suggestionsLabel = questionnaire.suggestions_label ?? "Any suggestions? (optional)";
|
|
117
|
+
|
|
118
|
+
// A multiline box ~4 lines tall (capped at ~5), derived from the theme's body line-height so
|
|
119
|
+
// it scales with the app's font config; padding is folded in so the lines are visible.
|
|
120
|
+
const opinionBox = feedbackInputHeights(t.fonts, t.spacing.md);
|
|
121
|
+
|
|
122
|
+
const content = (
|
|
123
|
+
<>
|
|
124
|
+
<Text style={[headingStyle(t.fonts), { color: t.colors.text }]}>{title}</Text>
|
|
125
|
+
|
|
126
|
+
{phase === "form" && (
|
|
127
|
+
<>
|
|
128
|
+
<Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
|
|
129
|
+
A few words is plenty. Only the first box is required.
|
|
130
|
+
</Text>
|
|
131
|
+
|
|
132
|
+
{/* MANDATORY opinion box - the only gate on Send. */}
|
|
133
|
+
<Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>{opinionLabel}</Text>
|
|
134
|
+
<TextInput
|
|
135
|
+
value={opinion}
|
|
136
|
+
onChangeText={setOpinion}
|
|
137
|
+
placeholder="e.g. it feels fast and clear"
|
|
138
|
+
placeholderTextColor={t.colors.textMuted}
|
|
139
|
+
multiline
|
|
140
|
+
textAlignVertical="top"
|
|
141
|
+
style={[
|
|
142
|
+
bodyStyle(t.fonts),
|
|
143
|
+
styles.input,
|
|
144
|
+
{
|
|
145
|
+
color: t.colors.text,
|
|
146
|
+
backgroundColor: t.colors.background,
|
|
147
|
+
borderRadius: t.radius.md,
|
|
148
|
+
borderColor: t.colors.border,
|
|
149
|
+
padding: t.spacing.md,
|
|
150
|
+
minHeight: opinionBox.minHeight,
|
|
151
|
+
maxHeight: opinionBox.maxHeight,
|
|
152
|
+
},
|
|
153
|
+
]}
|
|
154
|
+
/>
|
|
155
|
+
|
|
156
|
+
{/* OPTIONAL "help us improve" box - never gates Send. */}
|
|
157
|
+
<Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>{improveLabel}</Text>
|
|
158
|
+
<TextInput
|
|
159
|
+
value={improve}
|
|
160
|
+
onChangeText={setImprove}
|
|
161
|
+
placeholder="e.g. fewer steps to start"
|
|
162
|
+
placeholderTextColor={t.colors.textMuted}
|
|
163
|
+
style={[
|
|
164
|
+
bodyStyle(t.fonts),
|
|
165
|
+
styles.input,
|
|
166
|
+
{
|
|
167
|
+
color: t.colors.text,
|
|
168
|
+
backgroundColor: t.colors.background,
|
|
169
|
+
borderRadius: t.radius.md,
|
|
170
|
+
borderColor: t.colors.border,
|
|
171
|
+
paddingHorizontal: t.spacing.md,
|
|
172
|
+
paddingVertical: t.spacing.sm,
|
|
173
|
+
minHeight: t.button.height,
|
|
174
|
+
},
|
|
175
|
+
]}
|
|
176
|
+
/>
|
|
177
|
+
|
|
178
|
+
{/* OPTIONAL "suggestions" box - never gates Send. */}
|
|
179
|
+
<Text style={[captionStyle(t.fonts), { color: t.colors.textMuted }]}>
|
|
180
|
+
{suggestionsLabel}
|
|
181
|
+
</Text>
|
|
182
|
+
<TextInput
|
|
183
|
+
value={suggestions}
|
|
184
|
+
onChangeText={setSuggestions}
|
|
185
|
+
placeholder="e.g. a dark theme"
|
|
186
|
+
placeholderTextColor={t.colors.textMuted}
|
|
187
|
+
style={[
|
|
188
|
+
bodyStyle(t.fonts),
|
|
189
|
+
styles.input,
|
|
190
|
+
{
|
|
191
|
+
color: t.colors.text,
|
|
192
|
+
backgroundColor: t.colors.background,
|
|
193
|
+
borderRadius: t.radius.md,
|
|
194
|
+
borderColor: t.colors.border,
|
|
195
|
+
paddingHorizontal: t.spacing.md,
|
|
196
|
+
paddingVertical: t.spacing.sm,
|
|
197
|
+
minHeight: t.button.height,
|
|
198
|
+
},
|
|
199
|
+
]}
|
|
200
|
+
/>
|
|
201
|
+
|
|
202
|
+
<Button title="Send" onPress={send} variant="primary" fullWidth disabled={!canSend} />
|
|
203
|
+
{dismissible && (
|
|
204
|
+
<Pressable onPress={handleDismissTap} hitSlop={8}>
|
|
205
|
+
<Text style={[captionStyle(t.fonts), styles.link, { color: t.colors.textMuted }]}>
|
|
206
|
+
Not now
|
|
207
|
+
</Text>
|
|
208
|
+
</Pressable>
|
|
209
|
+
)}
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{phase === "thanks" && (
|
|
214
|
+
<Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted }]}>
|
|
215
|
+
Thank you. This helps us make it better.
|
|
216
|
+
</Text>
|
|
217
|
+
)}
|
|
218
|
+
</>
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<CenteredModal ref={modalRef} theme={t} dismissible={dismissible} onDismiss={resolve}>
|
|
223
|
+
{content}
|
|
224
|
+
</CenteredModal>
|
|
225
|
+
);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
QuestionnaireGate.displayName = "QuestionnaireGate";
|
|
229
|
+
|
|
230
|
+
const styles = StyleSheet.create({
|
|
231
|
+
input: { borderWidth: 1 },
|
|
232
|
+
link: { textAlign: "center", textDecorationLine: "underline" },
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
export default QuestionnaireGate;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decision.ts - pure decision logic for the questionnaire gate.
|
|
3
|
+
*
|
|
4
|
+
* `decideQuestionnaire` is the AI seam, mirroring `decideReview` exactly: ONE function
|
|
5
|
+
* that turns an optional injected decision into the live verdict. No injected decision →
|
|
6
|
+
* the local rules stand; a server decision present → it OVERRIDES them. The local firing
|
|
7
|
+
* rules (`evaluateGate` / `resolveRules`) and their signals are the SAME generic, pure
|
|
8
|
+
* helpers the review gate uses - reused here rather than duplicated, since a questionnaire
|
|
9
|
+
* verdict is structurally the same `{fire, reason}` contract.
|
|
10
|
+
*
|
|
11
|
+
* `buildQuestionnaireSubmission` is pure too, so the "opinion required, empties omitted"
|
|
12
|
+
* shaping of the POST body is verifiable in isolation (the kit has no runtime test runner).
|
|
13
|
+
*/
|
|
14
|
+
import { decideReview, evaluateGate, resolveRules } from "../reviews/decision";
|
|
15
|
+
import type {
|
|
16
|
+
QuestionnaireAnswers,
|
|
17
|
+
QuestionnaireDecision,
|
|
18
|
+
QuestionnaireMeta,
|
|
19
|
+
QuestionnaireSubmission,
|
|
20
|
+
} from "./types";
|
|
21
|
+
|
|
22
|
+
// Re-export the generic local-rule helpers under the questionnaire's own namespace so a
|
|
23
|
+
// host wires the fallback without reaching into the reviews module.
|
|
24
|
+
export { evaluateGate, resolveRules } from "../reviews/decision";
|
|
25
|
+
export type { GateSignals, GateRules } from "../reviews/decision";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The seam. `decision` absent → the local verdict stands; present → it overrides. Same
|
|
29
|
+
* contract philosophy as `decideReview(local, decision?)`.
|
|
30
|
+
*/
|
|
31
|
+
export const decideQuestionnaire = (
|
|
32
|
+
local: QuestionnaireDecision,
|
|
33
|
+
decision?: QuestionnaireDecision,
|
|
34
|
+
): QuestionnaireDecision => decideReview(local, decision);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the `POST /v1/questionnaires/{id}/responses` body. `opinion` is the only mandatory
|
|
38
|
+
* answer (trimmed); `improve`/`suggestions` are trimmed and sent as `null` when empty. The
|
|
39
|
+
* `meta` bucket carries the non-PII correlation fields (user_id / device_key / session_count),
|
|
40
|
+
* each defaulting to null. Free text lives ONLY here, never in analytics events.
|
|
41
|
+
*/
|
|
42
|
+
export const buildQuestionnaireSubmission = (input: {
|
|
43
|
+
answers: QuestionnaireAnswers;
|
|
44
|
+
sessionId?: string | null;
|
|
45
|
+
meta?: QuestionnaireMeta;
|
|
46
|
+
}): QuestionnaireSubmission => {
|
|
47
|
+
const opinion = input.answers.opinion.trim();
|
|
48
|
+
const improve = input.answers.improve?.trim();
|
|
49
|
+
const suggestions = input.answers.suggestions?.trim();
|
|
50
|
+
return {
|
|
51
|
+
session_id: input.sessionId ?? null,
|
|
52
|
+
answers: {
|
|
53
|
+
opinion,
|
|
54
|
+
improve: improve ? improve : null,
|
|
55
|
+
suggestions: suggestions ? suggestions : null,
|
|
56
|
+
},
|
|
57
|
+
meta: {
|
|
58
|
+
user_id: input.meta?.user_id ?? null,
|
|
59
|
+
device_key: input.meta?.device_key ?? null,
|
|
60
|
+
session_count: input.meta?.session_count ?? null,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
};
|