diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx
index 26c7968..54f904d 100644
--- a/apps/rebreak-native/app/_layout.tsx
+++ b/apps/rebreak-native/app/_layout.tsx
@@ -18,6 +18,7 @@ import { useAuthStore } from '../stores/auth';
import { useThemeStore } from '../stores/theme';
import { useLanguageStore } from '../stores/language';
import { BrandSplash } from '../components/BrandSplash';
+import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
import '../lib/i18n'; // i18next-Init via Side-Effect
import '../global.css';
@@ -71,6 +72,7 @@ function RootLayoutInner() {
return (
<>
+
['name'] {
+ if (platform === 'ios') return 'logo-apple';
+ if (platform === 'android') return 'logo-android';
+ return 'phone-portrait-outline';
+}
+
+function formatLastSeen(iso: string, t: (k: string, o?: any) => string): string {
+ const ms = Date.now() - new Date(iso).getTime();
+ const min = Math.floor(ms / 60_000);
+ if (min < 1) return t('settings.devices_just_now');
+ if (min < 60) return t('settings.devices_mins_ago', { count: min });
+ const hr = Math.floor(min / 60);
+ if (hr < 24) return t('settings.devices_hours_ago', { count: hr });
+ const day = Math.floor(hr / 24);
+ if (day < 30) return t('settings.devices_days_ago', { count: day });
+ return new Date(iso).toLocaleDateString(
+ Platform.OS === 'ios' ? undefined : 'de-DE',
+ { day: '2-digit', month: 'short', year: 'numeric' }
+ );
+}
+
+function DeviceLimitRow({
+ device,
+ removing,
+ onRemove,
+}: {
+ device: DeviceLimitDevice;
+ removing: boolean;
+ onRemove: (id: string) => void;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+ {device.name ?? device.model ?? device.platform}
+
+ {device.model && device.name && !device.name.includes(device.model) ? (
+
+ {device.model}
+
+ ) : null}
+
+
+
+ {formatLastSeen(device.lastSeenAt, t)}
+
+
+
+
+ {removing ? (
+
+ ) : (
+ onRemove(device.id)}
+ hitSlop={8}
+ style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
+ >
+
+
+ )}
+
+ );
+}
+
+export function DeviceLimitReachedSheet() {
+ const { t } = useTranslation();
+ const sheetRef = useRef(null);
+ const { visible, devices, max, plan, hide, removeDevice } = useDeviceLimitStore();
+ const [removingId, setRemovingId] = useState(null);
+
+ useEffect(() => {
+ if (visible) {
+ sheetRef.current?.present();
+ }
+ }, [visible]);
+
+ async function handleRemove(id: string) {
+ setRemovingId(id);
+ try {
+ await apiFetch(`/api/devices/${id}`, {
+ method: 'DELETE',
+ skipDeviceHeader: true,
+ });
+ removeDevice(id);
+
+ const remaining = useDeviceLimitStore.getState().devices;
+ if (remaining.length < max) {
+ sheetRef.current?.dismiss();
+ hide();
+ }
+ } finally {
+ setRemovingId(null);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ {t('device_limit.title')}
+
+
+ {t('device_limit.subtitle', { max, plan: plan.toUpperCase() })}
+
+
+
+ {devices.map((device, i) => (
+
+
+
+ ))}
+
+
+
+ {t('device_limit.hint')}
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/lib/api.ts b/apps/rebreak-native/lib/api.ts
index 0c6d98a..8d73b0d 100644
--- a/apps/rebreak-native/lib/api.ts
+++ b/apps/rebreak-native/lib/api.ts
@@ -1,6 +1,7 @@
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;
@@ -49,6 +50,20 @@ export async function apiFetch(
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}`);
}
diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json
index 6e2de2e..14b44b5 100644
--- a/apps/rebreak-native/locales/de.json
+++ b/apps/rebreak-native/locales/de.json
@@ -483,6 +483,12 @@
"devices_remove_desc": "Das Gerät wird freigegeben. Es kann sich beim nächsten Login erneut registrieren.",
"devices_remove_confirm": "Entfernen"
},
+ "device_limit": {
+ "title": "Geräte-Limit erreicht",
+ "subtitle": "{{max}} von {{max}} Geräten belegt ({{plan}}) — entferne ein Gerät um weiterzumachen",
+ "hint": "Entfernte Geräte können sich beim nächsten Login wieder registrieren.",
+ "remove_cta": "Gerät entfernen"
+ },
"urge": {
"title": "SOS — Atemübung",
"step_dashboard": "Start",
diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json
index 65fa117..44d8236 100644
--- a/apps/rebreak-native/locales/en.json
+++ b/apps/rebreak-native/locales/en.json
@@ -483,6 +483,12 @@
"devices_remove_desc": "The device slot will be freed. It can re-register on next sign-in.",
"devices_remove_confirm": "Remove"
},
+ "device_limit": {
+ "title": "Device limit reached",
+ "subtitle": "{{max}} of {{max}} device slots used ({{plan}}) — remove a device to continue",
+ "hint": "Removed devices can re-register on next sign-in.",
+ "remove_cta": "Remove device"
+ },
"urge": {
"title": "SOS — Breathing exercise",
"step_dashboard": "Start",
diff --git a/apps/rebreak-native/stores/deviceLimit.ts b/apps/rebreak-native/stores/deviceLimit.ts
new file mode 100644
index 0000000..f1e501b
--- /dev/null
+++ b/apps/rebreak-native/stores/deviceLimit.ts
@@ -0,0 +1,33 @@
+import { create } from 'zustand';
+
+export type DeviceLimitDevice = {
+ id: string;
+ deviceId: string;
+ platform: string;
+ model: string | null;
+ name: string | null;
+ lastSeenAt: string;
+ createdAt: string;
+};
+
+type DeviceLimitState = {
+ visible: boolean;
+ devices: DeviceLimitDevice[];
+ max: number;
+ plan: string;
+ show: (devices: DeviceLimitDevice[], max: number, plan: string) => void;
+ hide: () => void;
+ removeDevice: (id: string) => void;
+};
+
+export const useDeviceLimitStore = create((set) => ({
+ visible: false,
+ devices: [],
+ max: 0,
+ plan: 'free',
+
+ show: (devices, max, plan) => set({ visible: true, devices, max, plan }),
+ hide: () => set({ visible: false }),
+ removeDevice: (id) =>
+ set((s) => ({ devices: s.devices.filter((d) => d.id !== id) })),
+}));