/** * quiXzoom API client * Auth: identity-core JWT (stored in SecureStore) */ import * as SecureStore from 'expo-secure-store' const API_BASE = process.env.EXPO_PUBLIC_API_URL ?? 'https://api.quixzoom.com' const TOKEN_KEY = 'qxz_jwt' const REFRESH_KEY = 'qxz_refresh' // ─── Token management ────────────────────────────────────────────────────── export async function getToken(): Promise { return SecureStore.getItemAsync(TOKEN_KEY) } export async function setToken(token: string, refreshToken?: string): Promise { await SecureStore.setItemAsync(TOKEN_KEY, token) if (refreshToken) await SecureStore.setItemAsync(REFRESH_KEY, refreshToken) } export async function clearTokens(): Promise { await SecureStore.deleteItemAsync(TOKEN_KEY) await SecureStore.deleteItemAsync(REFRESH_KEY) } // ─── Base fetch with JWT ─────────────────────────────────────────────────── async function apiFetch( path: string, options: RequestInit = {}, ): Promise { const token = await getToken() const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record), } if (token) headers['Authorization'] = `Bearer ${token}` const res = await fetch(`${API_BASE}${path}`, { ...options, headers }) if (res.status === 401) { // Try refresh const refreshed = await attemptRefresh() if (refreshed) { headers['Authorization'] = `Bearer ${refreshed}` const retry = await fetch(`${API_BASE}${path}`, { ...options, headers }) if (!retry.ok) throw new ApiError(retry.status, await retry.text()) return retry.json() as Promise } throw new ApiError(401, 'Unauthorized') } if (!res.ok) throw new ApiError(res.status, await res.text()) return res.json() as Promise } async function attemptRefresh(): Promise { const refresh = await SecureStore.getItemAsync(REFRESH_KEY) if (!refresh) return null try { const res = await fetch(`${API_BASE}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refresh }), }) if (!res.ok) return null const data = await res.json() as { access_token: string; refresh_token?: string } await setToken(data.access_token, data.refresh_token) return data.access_token } catch { return null } } export class ApiError extends Error { constructor(public status: number, message: string) { super(message) this.name = 'ApiError' } } // ─── Types ───────────────────────────────────────────────────────────────── export type MissionStatus = 'open' | 'active' | 'completed' | 'cancelled' | 'expired' export type MissionCategory = 'infrastruktur' | 'vägar' | 'hamnar' | 'bryggor' | 'fritidshus' | 'miljö' | 'övrigt' export interface Mission { id: string title: string description: string status: MissionStatus category: MissionCategory latitude: number longitude: number city: string reward_amount: number // in öre (SEK * 100) reward_currency: string // 'SEK' deadline_at: string | null // ISO created_at: string distance_meters?: number // populated when fetching nearby } export interface MissionDetail extends Mission { instructions: string requirements: string[] max_submissions: number submission_count: number } export interface Submission { id: string mission_id: string zoomer_id: string status: 'pending' | 'approved' | 'rejected' media_urls: string[] created_at: string reward_paid: number } export interface Zoomer { id: string email: string display_name: string avatar_url: string | null level: number total_earned: number // öre missions_completed: number badges: Badge[] joined_at: string } export interface Badge { id: string label: string emoji: string earned_at: string } export interface EarningsDay { date: string // YYYY-MM-DD amount: number // öre missions: number } export interface EarningsSummary { today: number this_week: number total: number pending: number currency: string } export interface Payout { id: string amount: number currency: string status: 'pending' | 'approved' | 'paid' | 'rejected' created_at: string paid_at: string | null } export interface AuthResult { access_token: string refresh_token: string zoomer: Zoomer } // ─── Auth ────────────────────────────────────────────────────────────────── export const auth = { async login(email: string, password: string): Promise { const data = await apiFetch('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }), }) await setToken(data.access_token, data.refresh_token) return data }, async register(email: string, password: string, displayName?: string): Promise { const data = await apiFetch('/auth/register', { method: 'POST', body: JSON.stringify({ email, password, display_name: displayName }), }) await setToken(data.access_token, data.refresh_token) return data }, async logout(): Promise { try { await apiFetch('/auth/logout', { method: 'POST' }) } catch { // best-effort } await clearTokens() }, async me(): Promise { return apiFetch('/auth/me') }, } // ─── Missions ────────────────────────────────────────────────────────────── export const missions = { /** Nearby open missions sorted by distance */ async nearby(lat: number, lng: number, radiusKm = 50): Promise { const q = new URLSearchParams({ lat: lat.toString(), lng: lng.toString(), radius_km: radiusKm.toString(), status: 'open', }) return apiFetch(`/missions/near?${q}`) }, /** List all open missions (paginated) */ async list(page = 1, category?: MissionCategory): Promise { const q = new URLSearchParams({ page: page.toString(), status: 'open' }) if (category) q.set('category', category) return apiFetch(`/missions?${q}`) }, async get(id: string): Promise { return apiFetch(`/missions/${id}`) }, async accept(id: string): Promise<{ ok: boolean }> { return apiFetch<{ ok: boolean }>(`/missions/${id}/claim`, { method: 'PATCH' }) }, async submit(id: string, mediaUris: string[]): Promise { const form = new FormData() for (const uri of mediaUris) { const filename = uri.split('/').pop() ?? 'image.jpg' form.append('media', { uri, name: filename, type: 'image/jpeg', } as unknown as Blob) } const token = await getToken() const res = await fetch(`${API_BASE}/missions/${id}/submit`, { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, body: form, }) if (!res.ok) throw new ApiError(res.status, await res.text()) return res.json() as Promise }, /** My active/accepted missions */ async myActive(): Promise { return apiFetch('/missions/claims/active') }, } // ─── Wallet / Earnings ───────────────────────────────────────────────────── export interface WalletBalance { balance: number // öre currency: string pending_payouts: number } export const wallet = { async balance(): Promise { return apiFetch('/auth/wallet') }, } export const payouts = { async history(): Promise { return apiFetch('/payouts/history') }, async pending(): Promise { return apiFetch('/payouts/pending') }, async request(amount: number): Promise { return apiFetch('/payouts/request', { method: 'POST', body: JSON.stringify({ amount }), }) }, } // ─── Passwordless Auth ─────────────────────────────────────────────────── export interface PasswordlessRequest { request_id: string request_token: string status: 'pending' | 'scanned' | 'approved' | 'denied' | 'expired' device_info?: { type: string name: string browser?: string } created_at: string expires_at: string } export const passwordless = { /** Approve a passwordless login request */ async approve(requestToken: string): Promise<{ ok: boolean; zoomer?: Zoomer }> { return apiFetch<{ ok: boolean; zoomer?: Zoomer }>('/auth/passwordless/approve', { method: 'POST', body: JSON.stringify({ request_token: requestToken }), }) }, /** Deny a passwordless login request */ async deny(requestToken: string): Promise<{ ok: boolean }> { return apiFetch<{ ok: boolean }>('/auth/passwordless/deny', { method: 'POST', body: JSON.stringify({ request_token: requestToken }), }) }, /** Get pending auth requests for current user */ async pending(): Promise { return apiFetch('/auth/passwordless/pending') }, } // ─── Profile ─────────────────────────────────────────────────────────────── export const profile = { async get(): Promise { return apiFetch('/auth/me') }, async update(data: { display_name?: string }): Promise { return apiFetch('/auth/me', { method: 'PATCH', body: JSON.stringify(data), }) }, }