- 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>
91 lines
2.7 KiB
TypeScript
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;
|
|
}
|