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 { 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 (
|
||||
<>
|
||||
<StatusBar style="dark" />
|
||||
<DeviceLimitReachedSheet />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
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 { 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<T = any>(
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
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