fix(rn-platform-sdk): align providers with platform-service APIs
- Feature flags: GET /flags/poll legacy { flags } + optional userId
- Kill switch: GET /settings/kill-switch, map message to reason
- Broadcasts: GET /broadcasts, POST dismiss; map server message shape
- Surveys: GET /surveys/active; submit via start/response/complete
- Auth: register(); login/register bodies include productId
- Telemetry: map queued events to TelemetryEventSchema; RN Platform import
- prepare script runs tsc on install
Made-with: Cursor
This commit is contained in:
parent
e13d0cba6b
commit
e174335a9e
@ -37,6 +37,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"prepare": "tsc",
|
||||
"test": "vitest run --pool forks",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@ -15,6 +15,7 @@ export interface AuthState {
|
||||
|
||||
export interface AuthContextType extends AuthState {
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, displayName: string) => Promise<void>;
|
||||
loginWithGoogle: (idToken: string) => Promise<void>;
|
||||
loginWithApple: (idToken: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
@ -85,7 +86,7 @@ export function AuthProvider({
|
||||
try {
|
||||
const res = await sdk.fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ email, password, productId: sdk.config.productId }),
|
||||
});
|
||||
await handleTokenResponse(res);
|
||||
} catch (e: unknown) {
|
||||
@ -96,6 +97,28 @@ export function AuthProvider({
|
||||
[sdk, handleTokenResponse]
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (email: string, password: string, displayName: string) => {
|
||||
setState(s => ({ ...s, isLoading: true, error: null }));
|
||||
try {
|
||||
const res = await sdk.fetch('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
displayName,
|
||||
productId: sdk.config.productId,
|
||||
}),
|
||||
});
|
||||
await handleTokenResponse(res);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Registration failed';
|
||||
setState(s => ({ ...s, isLoading: false, error: msg }));
|
||||
}
|
||||
},
|
||||
[sdk, handleTokenResponse]
|
||||
);
|
||||
|
||||
const loginWithGoogle = useCallback(
|
||||
async (idToken: string) => {
|
||||
setState(s => ({ ...s, isLoading: true, error: null }));
|
||||
@ -174,6 +197,7 @@ export function AuthProvider({
|
||||
const value: AuthContextType = {
|
||||
...state,
|
||||
login,
|
||||
register,
|
||||
loginWithGoogle,
|
||||
loginWithApple,
|
||||
logout,
|
||||
|
||||
@ -45,11 +45,33 @@ export function BroadcastProvider({
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await sdk.fetch('/api/broadcasts/active');
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as InAppMessage[];
|
||||
setMessages(data);
|
||||
}
|
||||
const res = await sdk.fetch('/broadcasts');
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as {
|
||||
messages?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
ctaText?: string;
|
||||
ctaUrl?: string;
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent';
|
||||
dismissible?: boolean;
|
||||
expiresAt?: string;
|
||||
}>;
|
||||
};
|
||||
const raw = data.messages ?? [];
|
||||
const mapped: InAppMessage[] = raw.map(m => ({
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
body: m.body,
|
||||
type:
|
||||
m.priority === 'urgent' ? 'critical' : m.priority === 'high' ? 'warning' : 'info',
|
||||
action:
|
||||
m.ctaText && m.ctaUrl ? { label: m.ctaText, url: m.ctaUrl } : undefined,
|
||||
dismissible: m.dismissible !== false,
|
||||
expiresAt: m.expiresAt,
|
||||
}));
|
||||
setMessages(mapped);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
@ -58,8 +80,7 @@ export function BroadcastProvider({
|
||||
const dismiss = useCallback(
|
||||
(id: string) => {
|
||||
setMessages(prev => prev.filter(m => m.id !== id));
|
||||
// Fire-and-forget dismiss on server
|
||||
sdk.fetch(`/api/broadcasts/${id}/dismiss`, { method: 'POST' }).catch(() => {});
|
||||
sdk.fetch(`/broadcasts/${id}/dismiss`, { method: 'POST' }).catch(() => {});
|
||||
},
|
||||
[sdk]
|
||||
);
|
||||
|
||||
@ -30,31 +30,37 @@ interface FeatureFlagProviderProps {
|
||||
sdk: PlatformSDK;
|
||||
/** Poll interval in ms (default: 60000) */
|
||||
pollInterval?: number;
|
||||
/** Optional user id for targeted flag evaluation (GET /flags/poll). */
|
||||
userId?: string | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FeatureFlagProvider({
|
||||
sdk,
|
||||
pollInterval = 60_000,
|
||||
userId,
|
||||
children,
|
||||
}: FeatureFlagProviderProps): React.JSX.Element {
|
||||
const [flags, setFlags] = useState<Map<string, FeatureFlag>>(new Map());
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await sdk.fetch('/api/flags/poll');
|
||||
const qs = new URLSearchParams({ platform: 'mobile' });
|
||||
if (userId) qs.set('userId', userId);
|
||||
const res = await sdk.fetch(`/flags/poll?${qs.toString()}`);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as FeatureFlag[];
|
||||
const data = (await res.json()) as { flags?: Record<string, boolean> };
|
||||
const map = new Map<string, FeatureFlag>();
|
||||
for (const flag of data) {
|
||||
map.set(flag.key, flag);
|
||||
const raw = data.flags ?? {};
|
||||
for (const [key, enabled] of Object.entries(raw)) {
|
||||
map.set(key, { key, enabled, value: enabled });
|
||||
}
|
||||
setFlags(map);
|
||||
}
|
||||
} catch {
|
||||
/* fail-open: keep existing flags */
|
||||
}
|
||||
}, [sdk]);
|
||||
}, [sdk, userId]);
|
||||
|
||||
const isEnabled = useCallback(
|
||||
(key: string): boolean => {
|
||||
|
||||
@ -43,12 +43,16 @@ export function KillSwitchProvider({
|
||||
|
||||
const check = useCallback(async () => {
|
||||
try {
|
||||
const res = await sdk.fetch('/api/flags/kill-switch');
|
||||
const res = await sdk.fetch('/settings/kill-switch');
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { disabled?: boolean; reason?: string };
|
||||
const data = (await res.json()) as {
|
||||
disabled?: boolean;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
};
|
||||
setState({
|
||||
disabled: data.disabled ?? false,
|
||||
reason: data.reason,
|
||||
reason: data.reason ?? data.message,
|
||||
isLoading: false,
|
||||
});
|
||||
} else {
|
||||
|
||||
@ -52,11 +52,10 @@ export function SurveyProvider({
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await sdk.fetch('/api/surveys/active');
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as ActiveSurvey | null;
|
||||
setActiveSurvey(data ?? null);
|
||||
}
|
||||
const res = await sdk.fetch('/surveys/active');
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { survey?: ActiveSurvey | null };
|
||||
setActiveSurvey(data.survey ?? null);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
@ -64,10 +63,14 @@ export function SurveyProvider({
|
||||
|
||||
const submit = useCallback(
|
||||
async (surveyId: string, answers: Record<string, unknown>) => {
|
||||
await sdk.fetch(`/api/surveys/${surveyId}/respond`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ answers }),
|
||||
});
|
||||
await sdk.fetch(`/surveys/${surveyId}/start`, { method: 'POST' });
|
||||
for (const [questionId, answer] of Object.entries(answers)) {
|
||||
await sdk.fetch(`/surveys/${surveyId}/response`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ questionId, answer }),
|
||||
});
|
||||
}
|
||||
await sdk.fetch(`/surveys/${surveyId}/complete`, { method: 'POST', body: '{}' });
|
||||
setActiveSurvey(null);
|
||||
},
|
||||
[sdk]
|
||||
@ -76,7 +79,7 @@ export function SurveyProvider({
|
||||
const dismiss = useCallback(
|
||||
(surveyId: string) => {
|
||||
setActiveSurvey(null);
|
||||
sdk.fetch(`/api/surveys/${surveyId}/dismiss`, { method: 'POST' }).catch(() => {});
|
||||
sdk.fetch(`/surveys/${surveyId}/dismiss`, { method: 'POST' }).catch(() => {});
|
||||
},
|
||||
[sdk]
|
||||
);
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Telemetry module — React context + hook for event tracking in React Native apps.
|
||||
* Maps queued events to platform-service TelemetryEventSchema for POST /telemetry/events.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef, useEffect } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import type { PlatformSDK } from '../core.js';
|
||||
|
||||
export interface TelemetryEvent {
|
||||
@ -16,6 +18,22 @@ export interface TelemetryConfig {
|
||||
flushInterval?: number;
|
||||
/** Max batch size before auto-flush (default: 20) */
|
||||
maxBatchSize?: number;
|
||||
appVersion?: string;
|
||||
buildNumber?: string;
|
||||
/** Default: beta */
|
||||
releaseChannel?: 'dev' | 'beta' | 'prod';
|
||||
/** Stable anonymous id (e.g. from MMKV). If omitted, generated per app session. */
|
||||
getInstallId?: () => string;
|
||||
}
|
||||
|
||||
function randomUuid(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
interface TelemetryContextType {
|
||||
@ -43,22 +61,51 @@ export function TelemetryProvider({
|
||||
children,
|
||||
}: TelemetryProviderProps): React.JSX.Element {
|
||||
const queue = useRef<TelemetryEvent[]>([]);
|
||||
const sessionIdRef = useRef(randomUuid());
|
||||
const installIdRef = useRef<string | null>(null);
|
||||
const flushInterval = config?.flushInterval ?? 30_000;
|
||||
const maxBatchSize = config?.maxBatchSize ?? 20;
|
||||
|
||||
const resolveInstallId = useCallback((): string => {
|
||||
if (!installIdRef.current) {
|
||||
installIdRef.current = config?.getInstallId?.() ?? randomUuid();
|
||||
}
|
||||
return installIdRef.current;
|
||||
}, [config?.getInstallId]);
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (queue.current.length === 0) return;
|
||||
const batch = queue.current.splice(0);
|
||||
const os = Platform.OS;
|
||||
const platform = os === 'ios' ? 'ios' : os === 'android' ? 'android' : 'web';
|
||||
const osFamily = platform === 'ios' ? 'ios' : platform === 'android' ? 'android' : 'other';
|
||||
const events = batch.map(e => ({
|
||||
id: randomUuid(),
|
||||
productId: sdk.config.productId,
|
||||
anonymousInstallId: resolveInstallId(),
|
||||
sessionId: sessionIdRef.current,
|
||||
platform,
|
||||
channel: 'mobile_app' as const,
|
||||
osFamily,
|
||||
osVersion: String(Platform.Version ?? ''),
|
||||
appVersion: config?.appVersion ?? '0.0.0',
|
||||
buildNumber: config?.buildNumber ?? '0',
|
||||
releaseChannel: config?.releaseChannel ?? ('beta' as const),
|
||||
eventType: 'info' as const,
|
||||
module: 'app',
|
||||
eventName: e.name,
|
||||
occurredAt: e.timestamp ?? new Date().toISOString(),
|
||||
context: e.properties,
|
||||
}));
|
||||
try {
|
||||
await sdk.fetch('/api/telemetry/batch', {
|
||||
await sdk.fetch('/telemetry/events', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ events: batch }),
|
||||
body: JSON.stringify({ productId: sdk.config.productId, events }),
|
||||
});
|
||||
} catch {
|
||||
// Re-queue on failure (best-effort)
|
||||
queue.current.unshift(...batch);
|
||||
}
|
||||
}, [sdk]);
|
||||
}, [sdk, config?.appVersion, config?.buildNumber, config?.releaseChannel, resolveInstallId]);
|
||||
|
||||
const track = useCallback(
|
||||
(name: string, properties?: Record<string, unknown>) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user