chahinebrini 804d4a5861 feat(native): device-info api headers + DeviceLimitSheet UI + profile i18n sweep
- lib/api.ts: sends x-device-name + x-device-model + x-device-os headers
  (cached per session, URL-encoded). Backend persists into user_devices for
  visual differentiation in DeviceLimitSheet.
- DeviceLimitReachedSheet: renders name (primary) + model · OS-version
  (secondary), "Dieses Gerät"-Pill on isCurrent. Stale phantoms become
  distinguishable.
- Profile i18n sweep: 8 keys × 3 languages = 24 fixes — all {{var}} placeholders
  switched to %{var} matching i18next config (Vue-i18n leftover from Nuxt-port).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:16:22 +02:00

91 lines
2.7 KiB
TypeScript

import Constants from 'expo-constants';
import { supabase } from './supabase';
import { getDeviceInfo } from './deviceId';
import { useDeviceLimitStore } from '../stores/deviceLimit';
const apiUrl = Constants.expoConfig?.extra?.apiUrl as string;
type FetchOptions = Omit<RequestInit, 'body'> & {
body?: any;
/** Set true on bootstrap calls (device register) to skip x-device-id injection */
skipDeviceHeader?: boolean;
};
let cachedDeviceHeaders: Record<string, string> | null = null;
async function getDeviceHeaders(): Promise<Record<string, string>> {
if (cachedDeviceHeaders) return cachedDeviceHeaders;
const info = await getDeviceInfo().catch(() => null);
if (!info) return {};
cachedDeviceHeaders = {
'x-device-id': info.deviceId,
'x-platform': info.platform,
'x-device-name': encodeURIComponent(info.name),
'x-device-model': encodeURIComponent(info.model),
'x-device-os': encodeURIComponent(info.osVersion),
};
return cachedDeviceHeaders;
}
/**
* Wrapper für Backend-API-Calls mit automatischem Auth-Token.
* Pendant zum Nuxt-`useSafeFetch` aus apps/rebreak/.
*
* Backend antwortet mit { success, data, status } — wir entpacken `data`.
*/
export async function apiFetch<T = any>(
path: string,
options: FetchOptions = {}
): Promise<T> {
const session = (await supabase.auth.getSession()).data.session;
const { skipDeviceHeader, ...fetchOptions } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(fetchOptions.headers as Record<string, string>),
};
if (session?.access_token) {
headers.Authorization = `Bearer ${session.access_token}`;
}
if (!skipDeviceHeader) {
const deviceHeaders = await getDeviceHeaders();
Object.assign(headers, deviceHeaders);
}
const res = await fetch(`${apiUrl}${path}`, {
...fetchOptions,
headers,
body: fetchOptions.body ? JSON.stringify(fetchOptions.body) : undefined,
});
if (!res.ok) {
const text = await res.text();
if (res.status === 403) {
try {
const parsed = JSON.parse(text);
if (
parsed?.statusMessage === 'device_limit_reached' ||
parsed?.data?.error === 'device_limit_reached'
) {
const { devices, max, plan } = parsed.data;
useDeviceLimitStore.getState().show(devices ?? [], max ?? 0, plan ?? 'free');
}
} catch {}
}
throw new Error(`API ${res.status}: ${text}`);
}
const json = await res.json();
// Unwrap { success, data, status } — siehe useSafeFetch-Pattern in der Vue-App
if (json && typeof json === 'object' && 'success' in json && 'data' in json) {
return json.data as T;
}
return json as T;
}