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>
This commit is contained in:
parent
c776570106
commit
d857d2a7aa
@ -18,6 +18,7 @@ import { useAuthStore } from '../stores/auth';
|
|||||||
import { useThemeStore } from '../stores/theme';
|
import { useThemeStore } from '../stores/theme';
|
||||||
import { useLanguageStore } from '../stores/language';
|
import { useLanguageStore } from '../stores/language';
|
||||||
import { BrandSplash } from '../components/BrandSplash';
|
import { BrandSplash } from '../components/BrandSplash';
|
||||||
|
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
|
||||||
import '../lib/i18n'; // i18next-Init via Side-Effect
|
import '../lib/i18n'; // i18next-Init via Side-Effect
|
||||||
import '../global.css';
|
import '../global.css';
|
||||||
|
|
||||||
@ -71,6 +72,7 @@ function RootLayoutInner() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatusBar style="dark" />
|
<StatusBar style="dark" />
|
||||||
|
<DeviceLimitReachedSheet />
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
|
|||||||
236
apps/rebreak-native/components/DeviceLimitReachedSheet.tsx
Normal file
236
apps/rebreak-native/components/DeviceLimitReachedSheet.tsx
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
import { ActivityIndicator, Platform, Pressable, Text, View } from 'react-native';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { TrueSheet, type SheetDetent } from '@lodev09/react-native-true-sheet';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { colors } from '../lib/theme';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
import { useDeviceLimitStore, type DeviceLimitDevice } from '../stores/deviceLimit';
|
||||||
|
|
||||||
|
function platformIcon(
|
||||||
|
platform: string
|
||||||
|
): React.ComponentProps<typeof Ionicons>['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 (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.04)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={platformIcon(device.platform)} size={20} color={colors.text} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
flexShrink: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.name ?? device.model ?? device.platform}
|
||||||
|
</Text>
|
||||||
|
{device.model && device.name && !device.name.includes(device.model) ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.model}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>
|
||||||
|
<Ionicons name="time-outline" size={11} color={colors.textMuted} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatLastSeen(device.lastSeenAt, t)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{removing ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.error} />
|
||||||
|
) : (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onRemove(device.id)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={18} color={colors.error} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceLimitReachedSheet() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const sheetRef = useRef<TrueSheet>(null);
|
||||||
|
const { visible, devices, max, plan, hide, removeDevice } = useDeviceLimitStore();
|
||||||
|
const [removingId, setRemovingId] = useState<string | null>(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 (
|
||||||
|
<TrueSheet
|
||||||
|
ref={sheetRef}
|
||||||
|
detents={['auto', 1] satisfies SheetDetent[]}
|
||||||
|
cornerRadius={20}
|
||||||
|
grabber
|
||||||
|
onDidDismiss={hide}
|
||||||
|
>
|
||||||
|
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 32 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(239,68,68,0.1)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="phone-portrait-outline" size={20} color={colors.error} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('device_limit.title')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginBottom: 20,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('device_limit.subtitle', { max, plan: plan.toUpperCase() })}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(239,68,68,0.12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{devices.map((device, i) => (
|
||||||
|
<View
|
||||||
|
key={device.id}
|
||||||
|
style={{
|
||||||
|
borderBottomWidth: i < devices.length - 1 ? 1 : 0,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.04)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeviceLimitRow
|
||||||
|
device={device}
|
||||||
|
removing={removingId === device.id}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginTop: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('device_limit.hint')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TrueSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
import { getDeviceId, getPlatformName } from './deviceId';
|
import { getDeviceId, getPlatformName } from './deviceId';
|
||||||
|
import { useDeviceLimitStore } from '../stores/deviceLimit';
|
||||||
|
|
||||||
const apiUrl = Constants.expoConfig?.extra?.apiUrl as string;
|
const apiUrl = Constants.expoConfig?.extra?.apiUrl as string;
|
||||||
|
|
||||||
@ -49,6 +50,20 @@ export async function apiFetch<T = any>(
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
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}`);
|
throw new Error(`API ${res.status}: ${text}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -483,6 +483,12 @@
|
|||||||
"devices_remove_desc": "Das Gerät wird freigegeben. Es kann sich beim nächsten Login erneut registrieren.",
|
"devices_remove_desc": "Das Gerät wird freigegeben. Es kann sich beim nächsten Login erneut registrieren.",
|
||||||
"devices_remove_confirm": "Entfernen"
|
"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": {
|
"urge": {
|
||||||
"title": "SOS — Atemübung",
|
"title": "SOS — Atemübung",
|
||||||
"step_dashboard": "Start",
|
"step_dashboard": "Start",
|
||||||
|
|||||||
@ -483,6 +483,12 @@
|
|||||||
"devices_remove_desc": "The device slot will be freed. It can re-register on next sign-in.",
|
"devices_remove_desc": "The device slot will be freed. It can re-register on next sign-in.",
|
||||||
"devices_remove_confirm": "Remove"
|
"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": {
|
"urge": {
|
||||||
"title": "SOS — Breathing exercise",
|
"title": "SOS — Breathing exercise",
|
||||||
"step_dashboard": "Start",
|
"step_dashboard": "Start",
|
||||||
|
|||||||
33
apps/rebreak-native/stores/deviceLimit.ts
Normal file
33
apps/rebreak-native/stores/deviceLimit.ts
Normal file
@ -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<DeviceLimitState>((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) })),
|
||||||
|
}));
|
||||||
Loading…
x
Reference in New Issue
Block a user