@fanapps/react-native 0.2.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/README.md ADDED
@@ -0,0 +1,324 @@
1
+ # @fanapps/react-native
2
+
3
+ React Native SDK for the Fanapps API — Firebase-powered auth, paginated content hooks, and FCM push-target registration.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm add @fanapps/react-native \
9
+ @tanstack/react-query \
10
+ @react-native-firebase/app \
11
+ @react-native-firebase/auth
12
+ ```
13
+
14
+ `react`, `react-native`, `@tanstack/react-query`, `@react-native-firebase/app`, and `@react-native-firebase/auth` are **required** peer dependencies.
15
+
16
+ Optional peers (install only if you use the matching feature):
17
+
18
+ | Feature | Package |
19
+ | ----------------------------------- | ---------------------------------------------- |
20
+ | Push registration + topic subscribe | `@react-native-firebase/messaging` |
21
+ | Persist `targetId` across launches | `@react-native-async-storage/async-storage` |
22
+ | Google sign-in | `@react-native-google-signin/google-signin` |
23
+ | Apple sign-in (iOS) | `@invertase/react-native-apple-authentication` |
24
+
25
+ You also need Firebase's native config (`google-services.json` on Android, `GoogleService-Info.plist` on iOS) — see the [React Native Firebase docs](https://rnfirebase.io).
26
+
27
+ ## Quick start
28
+
29
+ ```tsx
30
+ import { FanappsProvider, useAuth, useEntries } from '@fanapps/react-native';
31
+ import AsyncStorage from '@react-native-async-storage/async-storage';
32
+
33
+ export default function App() {
34
+ return (
35
+ <FanappsProvider
36
+ apiKey={process.env.EXPO_PUBLIC_FANAPPS_TOKEN!}
37
+ project={process.env.EXPO_PUBLIC_FANAPPS_PROJECT!}
38
+ locale="en"
39
+ storage={AsyncStorage}
40
+ >
41
+ <Feed />
42
+ </FanappsProvider>
43
+ );
44
+ }
45
+
46
+ function Feed() {
47
+ const { user, status } = useAuth();
48
+ const { data, isLoading, error } = useEntries({ blueprint: 'news', perPage: 20 });
49
+
50
+ if (status === 'loading') return <Text>…</Text>;
51
+ if (isLoading) return <Text>Loading…</Text>;
52
+ if (error) return <Text>Failed: {error.message}</Text>;
53
+
54
+ return <FlatList data={data?.data} /* ... */ />;
55
+ }
56
+ ```
57
+
58
+ ## Provider props
59
+
60
+ | Prop | Type | Required | Description |
61
+ | ------------- | ---------------- | -------- | --------------------------------------------------------------------------------------------------------------- |
62
+ | `apiKey` | `string` | yes | App-level bearer token. Sent as `Authorization: Bearer <apiKey>`. |
63
+ | `project` | `string` | yes | Tenant UUID. Sent as `X-Tenant` header. |
64
+ | `locale` | `string` | no | Default locale (e.g. `"en"`). Reactive — changing it refetches all queries. |
65
+ | `baseUrl` | `string` | no | API base URL. Defaults to production. |
66
+ | `queryClient` | `QueryClient` | no | Reuse an existing React Query client. Otherwise one is created internally. |
67
+ | `storage` | `StorageAdapter` | no | Storage for persisted state (e.g. push target IDs). Defaults to AsyncStorage if installed; otherwise in-memory. |
68
+
69
+ ## Auth
70
+
71
+ `useAuth` wraps `@react-native-firebase/auth` and keeps the SDK in sync with Firebase's auth state. The SDK automatically attaches a fresh Firebase ID token as `X-Firebase-Token` on every request, so the backend can resolve the signed-in `AppUser`.
72
+
73
+ ```tsx
74
+ import { useAuth } from '@fanapps/react-native';
75
+
76
+ function Screen() {
77
+ const {
78
+ user, // AuthUser | null — Firebase user shape
79
+ status, // 'loading' | 'signed-in' | 'signed-out'
80
+ signInWithEmail,
81
+ signUpWithEmail,
82
+ sendPasswordResetEmail,
83
+ sendEmailVerification,
84
+ signInWithGoogle,
85
+ signInWithApple,
86
+ signOut,
87
+ } = useAuth();
88
+
89
+ // ...
90
+ }
91
+ ```
92
+
93
+ **Notes**
94
+
95
+ - `user` is sourced entirely from Firebase (`onAuthStateChanged`) — no backend round-trip on auth state change.
96
+ - `signInWithGoogle` throws a `FanappsAuthError` with code `peer/missing` if `@react-native-google-signin/google-signin` isn't installed. Same for `signInWithApple`.
97
+ - `signInWithApple` is iOS-only in v1. On Android it throws `auth/platform-not-supported`.
98
+ - Token refresh is handled by Firebase; the SDK calls `currentUser.getIdToken()` before each request, which returns a cached token and only refreshes when close to expiry.
99
+
100
+ ## Push notifications
101
+
102
+ The SDK registers the device's FCM token as a _push target_ on the backend, using the Appwrite-style targetId pattern: a client-generated, client-persisted ID that's stable across token rotations.
103
+
104
+ ### Register a device
105
+
106
+ ```tsx
107
+ import { useAuth, useRegisterDevice } from '@fanapps/react-native';
108
+ import { useEffect } from 'react';
109
+
110
+ function PushSetup() {
111
+ const { status } = useAuth();
112
+ const push = useRegisterDevice();
113
+
114
+ useEffect(() => {
115
+ if (status === 'signed-in') push.register();
116
+ else if (status === 'signed-out') push.unregister();
117
+ }, [status]);
118
+
119
+ return null;
120
+ }
121
+ ```
122
+
123
+ `register()` does all of:
124
+
125
+ 1. Requests notification permission (iOS prompt; no-op if already granted).
126
+ 2. Fetches the FCM token.
127
+ 3. Creates a push target on the backend (or updates an existing one if a `targetId` is stored locally).
128
+ 4. Persists the `targetId` via the `storage` adapter.
129
+ 5. Subscribes to `onTokenRefresh`; when FCM rotates the token, the SDK silently PATCHes the backend.
130
+
131
+ `unregister()` deletes the backend row and clears the stored `targetId`. Call it on sign-out.
132
+
133
+ ### Subscribe to topics
134
+
135
+ FCM topics are the cheapest way to broadcast — no backend round-trip, no storage needed.
136
+
137
+ ```tsx
138
+ import { useTopicSubscription } from '@fanapps/react-native';
139
+
140
+ const topics = useTopicSubscription();
141
+ await topics.subscribe('news');
142
+ await topics.unsubscribe('news');
143
+ ```
144
+
145
+ The backend has no visibility into who subscribed to which topic — that's FCM's job. Your dashboard just sends a message to the topic name.
146
+
147
+ ### Displaying notifications
148
+
149
+ The SDK does **not** manage notification display or tap handling. Set up handlers yourself using `@react-native-firebase/messaging`:
150
+
151
+ ```tsx
152
+ import messaging from '@react-native-firebase/messaging';
153
+
154
+ messaging().onMessage(async remote => {
155
+ // foreground notification
156
+ });
157
+
158
+ messaging().onNotificationOpenedApp(remote => {
159
+ // user tapped a background notification
160
+ });
161
+ ```
162
+
163
+ ## Content hooks
164
+
165
+ All hooks accept an `options` arg with `{ locale?, enabled? }` to override the provider locale per call or pause the query.
166
+
167
+ ### Entries
168
+
169
+ ```ts
170
+ useEntries(filters?: EntryFilters, options?) // Paginated<Entry>
171
+ useEntry(id, options?) // Entry
172
+ ```
173
+
174
+ Filters: `blueprint`, `collection`, `slug`, `published`, `sort` (e.g. `-date`), `perPage`, `page`.
175
+
176
+ ### Articles
177
+
178
+ ```ts
179
+ useArticles(filters?: ArticleFilters, options?) // Paginated<Article>
180
+ useArticle(id, options?) // Article
181
+ ```
182
+
183
+ Filters: `is_published`, `sort`, `perPage`, `page`.
184
+
185
+ ### Trivia
186
+
187
+ ```ts
188
+ useTrivias(options?) // Trivia[]
189
+ useTrivia(id, options?) // Trivia
190
+ useSubmitTriviaResponse() // mutation
191
+ ```
192
+
193
+ ```tsx
194
+ const submit = useSubmitTriviaResponse();
195
+ submit.mutate(
196
+ { triviaId, answerId },
197
+ {
198
+ onSuccess: ({ is_correct, points_awarded }) => {
199
+ // ...
200
+ },
201
+ },
202
+ );
203
+ ```
204
+
205
+ On success the mutation invalidates the related `useTrivia(id)` query so `responses_count` and other derived values refresh.
206
+
207
+ ## Escape hatch
208
+
209
+ For endpoints not covered by a hook:
210
+
211
+ ```tsx
212
+ import { useFanappsClient } from '@fanapps/react-native';
213
+
214
+ function Thing() {
215
+ const client = useFanappsClient();
216
+ // client.get('/api/custom', { foo: 'bar' })
217
+ // client.post('/api/custom', { ... })
218
+ // client.patch('/api/custom/1', { ... })
219
+ // client.delete('/api/custom/1')
220
+ }
221
+ ```
222
+
223
+ All client calls carry the same `Authorization`, `X-Tenant`, and `X-Firebase-Token` headers as the hooks.
224
+
225
+ ## Errors
226
+
227
+ - Non-2xx HTTP responses throw `FanappsError` with `{ status, body, message }`.
228
+ - Auth flows throw `FanappsAuthError` with a `code` field. Common codes: `peer/load-failed` (module can't be loaded — usually a Metro symlink issue, see below), `peer/unexpected-shape` (loaded but wrong interop shape), `auth/platform-not-supported`, `auth/not-supported`, `auth/no-token`.
229
+
230
+ ```tsx
231
+ import { FanappsError, FanappsAuthError } from '@fanapps/react-native';
232
+
233
+ const { error } = useEntries();
234
+ if (error instanceof FanappsError && error.status === 401) {
235
+ // sign-out, etc.
236
+ }
237
+
238
+ try {
239
+ await signInWithGoogle();
240
+ } catch (e) {
241
+ if (e instanceof FanappsAuthError && e.code === 'peer/load-failed') {
242
+ // Google package couldn't be loaded — see "Symlinked installs" section
243
+ }
244
+ }
245
+ ```
246
+
247
+ ## Storage adapter
248
+
249
+ `FanappsProvider`'s `storage` prop accepts anything with this shape:
250
+
251
+ ```ts
252
+ interface StorageAdapter {
253
+ getItem(key: string): Promise<string | null>;
254
+ setItem(key: string, value: string): Promise<void>;
255
+ removeItem(key: string): Promise<void>;
256
+ }
257
+ ```
258
+
259
+ `@react-native-async-storage/async-storage`'s default export matches this exactly. If you want to use `expo-secure-store` or a custom backend, wrap it in an adapter.
260
+
261
+ If no `storage` prop is passed and AsyncStorage isn't installed, the SDK falls back to in-memory storage — the `targetId` will be lost on app restart, and a new push target row will be created on the next `register()` call.
262
+
263
+ ## Symlinked installs (local development)
264
+
265
+ If you link the SDK via `pnpm link`, a filesystem symlink, or `file:` protocol, Metro will often fail to resolve peer dependencies like `@react-native-firebase/auth` from the SDK's location — even though they're installed in your app. You'll see:
266
+
267
+ ```
268
+ FanappsAuthError: @fanapps/react-native could not load @react-native-firebase/auth:
269
+ Unable to resolve module '@react-native-firebase/auth'
270
+ ```
271
+
272
+ The error code on the thrown `FanappsAuthError` is `peer/load-failed`. Two fixes:
273
+
274
+ ### Option A — `yalc` (recommended)
275
+
276
+ `yalc` copies files instead of symlinking, so Metro has nothing special to resolve. From the SDK directory:
277
+
278
+ ```sh
279
+ yalc publish
280
+ ```
281
+
282
+ In your app:
283
+
284
+ ```sh
285
+ yalc add @fanapps/react-native
286
+ pnpm install
287
+ ```
288
+
289
+ When you change the SDK: `pnpm build && yalc push` — all linked apps update immediately.
290
+
291
+ ### Option B — Metro config with `extraNodeModules`
292
+
293
+ If you must keep the symlink (e.g. you want live SDK changes without a push step), tell Metro to always resolve peer deps from the app's `node_modules`:
294
+
295
+ ```js
296
+ // metro.config.js
297
+ const { getDefaultConfig } = require('@react-native/metro-config');
298
+ const path = require('path');
299
+
300
+ const config = getDefaultConfig(__dirname);
301
+
302
+ config.resolver.unstable_enableSymlinks = true;
303
+ config.resolver.extraNodeModules = new Proxy(
304
+ {},
305
+ { get: (_, name) => path.resolve(__dirname, 'node_modules', String(name)) },
306
+ );
307
+ config.watchFolders = [
308
+ path.resolve(__dirname, '../path/to/fanapps/resources/sdk/react-native'),
309
+ ];
310
+
311
+ module.exports = config;
312
+ ```
313
+
314
+ Then clear Metro cache: `npx react-native start --reset-cache` (or `expo start -c`).
315
+
316
+ The `extraNodeModules` Proxy forces every module lookup — including ones originating inside the symlinked SDK — to resolve against your app's `node_modules`. This avoids duplicate copies of `react` / `react-native` / `@react-native-firebase/*` too, which would otherwise cause "Invalid hook call" errors.
317
+
318
+ ## Build
319
+
320
+ ```sh
321
+ pnpm install
322
+ pnpm build # tsup → dist/
323
+ pnpm typecheck # tsc --noEmit
324
+ ```
@@ -0,0 +1,238 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { QueryClient, UseQueryResult, UseMutationResult } from '@tanstack/react-query';
3
+ import { ReactNode } from 'react';
4
+
5
+ interface StorageAdapter {
6
+ getItem(key: string): Promise<string | null>;
7
+ setItem(key: string, value: string): Promise<void>;
8
+ removeItem(key: string): Promise<void>;
9
+ }
10
+
11
+ type Locale = string;
12
+ type EntryData = Record<string, unknown>;
13
+ interface Entry {
14
+ id: string | number;
15
+ slug: string;
16
+ published: boolean;
17
+ date: string | null;
18
+ order: number | null;
19
+ collection: string | null;
20
+ blueprint: string;
21
+ data: EntryData;
22
+ created_at: string;
23
+ updated_at: string;
24
+ locale: Locale;
25
+ available_locales?: Locale[];
26
+ }
27
+ interface Article {
28
+ id: string | number;
29
+ title: string;
30
+ content: string;
31
+ is_published: boolean;
32
+ featured_image_url: string | null;
33
+ created_at: string;
34
+ updated_at: string;
35
+ locale: Locale;
36
+ available_locales?: Locale[];
37
+ }
38
+ interface TriviaAnswer {
39
+ id: string | number;
40
+ text: string;
41
+ is_correct: boolean;
42
+ sort_order: number;
43
+ }
44
+ interface Trivia {
45
+ id: string | number;
46
+ title: string;
47
+ description: string | null;
48
+ featured_image_id: string | number | null;
49
+ featured_image?: {
50
+ id: string | number;
51
+ url: string;
52
+ [key: string]: unknown;
53
+ } | null;
54
+ awards_points: boolean;
55
+ points_amount: number;
56
+ max_time_seconds: number | null;
57
+ show_correct_after: boolean;
58
+ starts_at: string | null;
59
+ ends_at: string | null;
60
+ answers: TriviaAnswer[];
61
+ responses_count?: number;
62
+ created_at: string;
63
+ updated_at: string;
64
+ }
65
+ interface TriviaResponseResult {
66
+ is_correct: boolean;
67
+ points_awarded: number;
68
+ correct_answer?: TriviaAnswer;
69
+ }
70
+ interface PaginationMeta {
71
+ current_page: number;
72
+ last_page: number;
73
+ per_page: number;
74
+ total: number;
75
+ from: number | null;
76
+ to: number | null;
77
+ path?: string;
78
+ }
79
+ interface PaginationLinks {
80
+ first: string | null;
81
+ last: string | null;
82
+ prev: string | null;
83
+ next: string | null;
84
+ }
85
+ interface Paginated<T> {
86
+ data: T[];
87
+ meta: PaginationMeta;
88
+ links: PaginationLinks;
89
+ }
90
+ interface PaginationParams {
91
+ page?: number;
92
+ perPage?: number;
93
+ sort?: string;
94
+ }
95
+ interface EntryFilters extends PaginationParams {
96
+ blueprint?: string;
97
+ collection?: string;
98
+ slug?: string;
99
+ published?: boolean;
100
+ }
101
+ interface ArticleFilters extends PaginationParams {
102
+ is_published?: boolean;
103
+ }
104
+ interface HookOptions {
105
+ locale?: Locale;
106
+ enabled?: boolean;
107
+ }
108
+ type AuthStatus = 'loading' | 'signed-in' | 'signed-out';
109
+ interface AuthUser {
110
+ uid: string;
111
+ email: string | null;
112
+ displayName: string | null;
113
+ photoURL: string | null;
114
+ phoneNumber: string | null;
115
+ isAnonymous: boolean;
116
+ providerId: string | null;
117
+ }
118
+ interface PushTarget {
119
+ id: string;
120
+ app_user_id: number | string | null;
121
+ identifier: string;
122
+ provider: 'fcm' | 'apns';
123
+ provider_id: string | null;
124
+ device_brand: string | null;
125
+ device_model: string | null;
126
+ os_name: string | null;
127
+ os_version: string | null;
128
+ last_seen_at: string | null;
129
+ created_at: string;
130
+ updated_at: string;
131
+ }
132
+ interface CreatePushTargetInput {
133
+ targetId: string;
134
+ identifier: string;
135
+ providerId?: string;
136
+ deviceBrand?: string;
137
+ deviceModel?: string;
138
+ osName?: string;
139
+ osVersion?: string;
140
+ }
141
+
142
+ interface FanappsProviderProps {
143
+ apiKey: string;
144
+ project: string;
145
+ locale?: Locale;
146
+ baseUrl?: string;
147
+ queryClient?: QueryClient;
148
+ storage?: StorageAdapter;
149
+ children: ReactNode;
150
+ }
151
+ declare function FanappsProvider({ apiKey, project, locale, baseUrl, queryClient, storage, children, }: FanappsProviderProps): react_jsx_runtime.JSX.Element;
152
+
153
+ interface FanappsClientOptions {
154
+ apiKey: string;
155
+ project: string;
156
+ baseUrl: string;
157
+ getIdToken?: () => Promise<string | null>;
158
+ }
159
+ type QueryParams = Record<string, unknown>;
160
+ declare class FanappsClient {
161
+ private readonly apiKey;
162
+ private readonly project;
163
+ private readonly baseUrl;
164
+ private readonly getIdToken?;
165
+ constructor(options: FanappsClientOptions);
166
+ get<T>(path: string, params?: QueryParams, signal?: AbortSignal): Promise<T>;
167
+ post<T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T>;
168
+ patch<T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T>;
169
+ delete<T>(path: string, signal?: AbortSignal): Promise<T>;
170
+ createPushTarget(input: CreatePushTargetInput): Promise<PushTarget>;
171
+ updatePushTarget(targetId: string, identifier: string): Promise<PushTarget>;
172
+ deletePushTarget(targetId: string): Promise<void>;
173
+ private request;
174
+ private buildUrl;
175
+ }
176
+
177
+ declare class FanappsError extends Error {
178
+ readonly status: number;
179
+ readonly body: unknown;
180
+ constructor(message: string, status: number, body: unknown);
181
+ }
182
+
183
+ declare class FanappsAuthError extends Error {
184
+ readonly code: string;
185
+ constructor(message: string, code: string);
186
+ }
187
+
188
+ declare function useFanappsClient(): FanappsClient;
189
+
190
+ interface UseAuthResult {
191
+ user: AuthUser | null;
192
+ status: AuthStatus;
193
+ signInWithEmail(email: string, password: string): Promise<AuthUser>;
194
+ signUpWithEmail(email: string, password: string): Promise<AuthUser>;
195
+ sendPasswordResetEmail(email: string): Promise<void>;
196
+ sendEmailVerification(): Promise<void>;
197
+ signInWithGoogle(): Promise<AuthUser>;
198
+ signInWithApple(): Promise<AuthUser>;
199
+ signOut(): Promise<void>;
200
+ }
201
+ declare function useAuth(): UseAuthResult;
202
+
203
+ declare function useEntries(filters?: EntryFilters, options?: HookOptions): UseQueryResult<Paginated<Entry>>;
204
+
205
+ declare function useEntry(id: string | number | null | undefined, options?: HookOptions): UseQueryResult<Entry>;
206
+
207
+ declare function useArticles(filters?: ArticleFilters, options?: HookOptions): UseQueryResult<Paginated<Article>>;
208
+
209
+ declare function useArticle(id: string | number | null | undefined, options?: HookOptions): UseQueryResult<Article>;
210
+
211
+ interface UseRegisterDeviceResult {
212
+ targetId: string | null;
213
+ token: string | null;
214
+ target: PushTarget | null;
215
+ isRegistering: boolean;
216
+ error: Error | null;
217
+ register(): Promise<PushTarget>;
218
+ unregister(): Promise<void>;
219
+ }
220
+ declare function useRegisterDevice(): UseRegisterDeviceResult;
221
+
222
+ declare function useTrivias(options?: HookOptions): UseQueryResult<Trivia[]>;
223
+
224
+ declare function useTrivia(id: string | number | null | undefined, options?: HookOptions): UseQueryResult<Trivia>;
225
+
226
+ interface SubmitTriviaResponseInput {
227
+ triviaId: string | number;
228
+ answerId: string | number;
229
+ }
230
+ declare function useSubmitTriviaResponse(): UseMutationResult<TriviaResponseResult, FanappsError, SubmitTriviaResponseInput>;
231
+
232
+ interface UseTopicSubscriptionResult {
233
+ subscribe(topic: string): Promise<void>;
234
+ unsubscribe(topic: string): Promise<void>;
235
+ }
236
+ declare function useTopicSubscription(): UseTopicSubscriptionResult;
237
+
238
+ export { type Article, type ArticleFilters, type AuthStatus, type AuthUser, type CreatePushTargetInput, type Entry, type EntryData, type EntryFilters, FanappsAuthError, FanappsClient, type FanappsClientOptions, FanappsError, FanappsProvider, type FanappsProviderProps, type HookOptions, type Locale, type Paginated, type PaginationLinks, type PaginationMeta, type PaginationParams, type PushTarget, type StorageAdapter, type SubmitTriviaResponseInput, type Trivia, type TriviaAnswer, type TriviaResponseResult, type UseAuthResult, type UseRegisterDeviceResult, type UseTopicSubscriptionResult, useArticle, useArticles, useAuth, useEntries, useEntry, useFanappsClient, useRegisterDevice, useSubmitTriviaResponse, useTopicSubscription, useTrivia, useTrivias };