diff --git a/packages/react-native-platform-sdk/package.json b/packages/react-native-platform-sdk/package.json index ce80f4cd..1b25981a 100644 --- a/packages/react-native-platform-sdk/package.json +++ b/packages/react-native-platform-sdk/package.json @@ -37,6 +37,7 @@ }, "scripts": { "build": "tsc", + "prepare": "tsc", "test": "vitest run --pool forks", "lint": "eslint src/**/*.ts", "typecheck": "tsc --noEmit" diff --git a/packages/react-native-platform-sdk/src/auth/index.ts b/packages/react-native-platform-sdk/src/auth/index.ts index 28cf29ac..a5af0122 100644 --- a/packages/react-native-platform-sdk/src/auth/index.ts +++ b/packages/react-native-platform-sdk/src/auth/index.ts @@ -15,6 +15,7 @@ export interface AuthState { export interface AuthContextType extends AuthState { login: (email: string, password: string) => Promise; + register: (email: string, password: string, displayName: string) => Promise; loginWithGoogle: (idToken: string) => Promise; loginWithApple: (idToken: string) => Promise; logout: () => Promise; @@ -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, diff --git a/packages/react-native-platform-sdk/src/broadcasts/index.ts b/packages/react-native-platform-sdk/src/broadcasts/index.ts index e9179435..3d29d960 100644 --- a/packages/react-native-platform-sdk/src/broadcasts/index.ts +++ b/packages/react-native-platform-sdk/src/broadcasts/index.ts @@ -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] ); diff --git a/packages/react-native-platform-sdk/src/feature-flags/index.ts b/packages/react-native-platform-sdk/src/feature-flags/index.ts index f96a3c21..77825ac7 100644 --- a/packages/react-native-platform-sdk/src/feature-flags/index.ts +++ b/packages/react-native-platform-sdk/src/feature-flags/index.ts @@ -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>(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 }; const map = new Map(); - 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 => { diff --git a/packages/react-native-platform-sdk/src/kill-switch/index.ts b/packages/react-native-platform-sdk/src/kill-switch/index.ts index 01ae08b9..b9f5f16a 100644 --- a/packages/react-native-platform-sdk/src/kill-switch/index.ts +++ b/packages/react-native-platform-sdk/src/kill-switch/index.ts @@ -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 { diff --git a/packages/react-native-platform-sdk/src/surveys/index.ts b/packages/react-native-platform-sdk/src/surveys/index.ts index 98d8cd21..f62654be 100644 --- a/packages/react-native-platform-sdk/src/surveys/index.ts +++ b/packages/react-native-platform-sdk/src/surveys/index.ts @@ -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) => { - 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] ); diff --git a/packages/react-native-platform-sdk/src/telemetry/index.ts b/packages/react-native-platform-sdk/src/telemetry/index.ts index ebcd035f..3cced99d 100644 --- a/packages/react-native-platform-sdk/src/telemetry/index.ts +++ b/packages/react-native-platform-sdk/src/telemetry/index.ts @@ -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([]); + const sessionIdRef = useRef(randomUuid()); + const installIdRef = useRef(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) => {