chahinebrini 51697c3aa4 feat(tier): plan-change briefing sheet + over-limit cards (Phase 2 UI)
- components/plan/PlanChangeSheet.tsx — upgrade/downgrade briefing per pricing-tiers.md §4
  (fetches GET /api/plan/change-preview; gains/keeps/changes; recovery-safety line;
  billing hint w/o purchase button; CTA row, no 'are you sure?' interstitial)
- debug.tsx: PlanOverrideToggle routes every flip through PlanChangeSheet first
- devices.tsx + protectedDevices.ts: 'degraded' status (red, inline 'protection expired —
  remove the profile yourself' hint, no green checkmark); maxProtectedDevices limit hint
- mail.tsx + MailAccountCard.tsx + useMailStatus.ts: over-limit banner + paused-account
  greyed-out + PausedBadge (all defensive — only shows if backend sends the  field)
- blocker.tsx: free-tier transparency hint ('Grundschutz aktiv — voller Schutz: Pro/Legend')
  + custom-domain over-limit banner
- locales: plan.change.* + plan_limit.* (de + en)

tsc clean. Backend side (GET /api/plan/change-preview, paused/degraded fields) in progress
in parallel — UI built defensively to work before it lands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:21:47 +02:00

304 lines
8.1 KiB
TypeScript

import { useEffect, useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../lib/theme';
import { useMe, invalidateMe, type Plan } from '../hooks/useMe';
import { apiFetch } from '../lib/api';
import { PlanChangeSheet } from '../components/plan/PlanChangeSheet';
export default function DebugScreen() {
const router = useRouter();
const colors = useColors();
const { me } = useMe();
useEffect(() => {
if (!__DEV__) {
router.replace('/');
}
}, [router]);
if (!__DEV__) {
return <View style={{ flex: 1, backgroundColor: colors.bg }} />;
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={['top']}>
<View
style={{
paddingHorizontal: 12,
paddingTop: 4,
paddingBottom: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.06)',
}}
>
<TouchableOpacity
onPress={() => router.back()}
hitSlop={8}
activeOpacity={0.6}
style={{ width: 40, height: 40, alignItems: 'center', justifyContent: 'center' }}
>
<Ionicons name="chevron-back" size={26} color={colors.text} />
</TouchableOpacity>
<Text style={{ fontSize: 20, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
Debug
</Text>
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 60 }}
showsVerticalScrollIndicator={false}
>
<View
style={{
backgroundColor: '#fef3c7',
borderRadius: 14,
padding: 14,
marginBottom: 20,
borderWidth: 1,
borderColor: '#fde68a',
flexDirection: 'row',
gap: 10,
alignItems: 'flex-start',
}}
>
<Ionicons name="warning-outline" size={20} color="#b45309" style={{ marginTop: 1 }} />
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#78350f' }}>
Dev only
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#92400e',
marginTop: 4,
lineHeight: 17,
}}
>
Diese Page ist nur in __DEV__ verfügbar. Production-Builds redirecten auf /.
</Text>
</View>
</View>
{me ? (
<PlanOverrideToggle
colors={colors}
userId={me.id}
currentPlan={me.plan}
/>
) : null}
<DebugStub
title="LLM-Provider Toggle"
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
icon="bulb-outline"
/>
<DebugStub
title="TTS-Provider Toggle"
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
icon="volume-high-outline"
/>
<DebugStub
title="Bench-Output"
subtitle="TTS/LLM-Latenz-Logs anzeigen (TODO)"
icon="speedometer-outline"
/>
</ScrollView>
</SafeAreaView>
);
}
const PLANS: Plan[] = ['free', 'pro', 'legend'];
const PLAN_COLOR: Record<Plan, string> = {
free: '#737373',
pro: '#007AFF',
legend: '#f59e0b',
};
function PlanOverrideToggle({
colors,
userId,
currentPlan,
}: {
colors: import('../lib/theme').ColorScheme;
userId: string;
currentPlan: Plan;
}) {
const [loading, setLoading] = useState(false);
const [sheetTarget, setSheetTarget] = useState<Plan | null>(null);
async function applyPlanSwitch(plan: Plan) {
setLoading(true);
try {
await apiFetch('/api/dev/set-plan', {
method: 'POST',
body: { plan },
});
invalidateMe();
} catch (e: unknown) {
Alert.alert('Fehler', e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}
function switchPlan(plan: Plan) {
if (plan === currentPlan) return;
setSheetTarget(plan);
}
return (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="star-outline" size={18} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
Plan-Override (DEV)
</Text>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 3,
lineHeight: 17,
}}
>
POST /api/dev/set-plan nur staging
</Text>
</View>
</View>
<View style={{ flexDirection: 'row', gap: 8 }}>
{PLANS.map((plan) => {
const isActive = plan === currentPlan;
const accent = PLAN_COLOR[plan];
return (
<TouchableOpacity
key={plan}
onPress={() => switchPlan(plan)}
disabled={loading || isActive}
activeOpacity={0.7}
style={{
flex: 1,
paddingVertical: 10,
borderRadius: 10,
alignItems: 'center',
backgroundColor: isActive ? accent : colors.surfaceElevated,
opacity: loading ? 0.5 : 1,
}}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: isActive ? '#ffffff' : colors.textMuted,
textTransform: 'capitalize',
}}
>
{plan}
</Text>
</TouchableOpacity>
);
})}
</View>
{sheetTarget ? (
<PlanChangeSheet
visible={sheetTarget !== null}
targetPlan={sheetTarget}
onConfirm={() => {
const t = sheetTarget;
setSheetTarget(null);
applyPlanSwitch(t);
}}
onClose={() => setSheetTarget(null)}
/>
) : null}
</View>
);
}
function DebugStub({
title,
subtitle,
icon,
}: {
title: string;
subtitle: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
}) {
const colors = useColors();
return (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
flexDirection: 'row',
gap: 12,
alignItems: 'flex-start',
opacity: 0.6,
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={18} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>{title}</Text>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 3,
lineHeight: 17,
}}
>
{subtitle}
</Text>
</View>
</View>
);
}