chahinebrini d857d2a7aa feat(devices): global Device-Limit-Reached handler + recovery sheet
Backend wirft 403 device_limit_reached für ALLE auth'd endpoints sobald User über
plan-limit ist. Bisheriges Frontend hat silent gefailt → Profile/Notifications/etc
zeigten nichts mehr, User war verwirrt.

Now:
- lib/api.ts: 403 device_limit_reached intercepten, parse error.data.devices,
  trigger useDeviceLimitStore.show()
- stores/deviceLimit.ts: Zustand store (visible, devices, max, plan, show/hide)
- components/DeviceLimitReachedSheet.tsx: TrueSheet (UISheetPresentationController)
  Auto-präsentiert wenn store visible, zeigt device-list mit trash-button per Eintrag,
  DELETE /api/devices/:id mit skipDeviceHeader: true (sonst circular 403)
- app/_layout.tsx: <DeviceLimitReachedSheet /> als globaler overlay vor <Stack>
- i18n: device_limit_* keys DE+EN

UX: User sieht jetzt sofort native bottom-sheet mit erklärung + actionable
device-list statt silent fail. Auto-close wenn devices.length < max nach delete.

TS-fix: detents={['auto', 1] satisfies SheetDetent[]}, onDidDismiss statt onDismiss
(prop heißt anders in TrueSheet API).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:27:33 +02:00

78 lines
2.2 KiB
TypeScript

import Constants from 'expo-constants';
import { supabase } from './supabase';
import { getDeviceId, getPlatformName } 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;
};
/**
* 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 deviceId = await getDeviceId().catch(() => null);
if (deviceId) {
headers['x-device-id'] = deviceId;
headers['x-platform'] = getPlatformName();
}
}
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;
}