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>
This commit is contained in:
chahinebrini 2026-05-11 16:21:47 +02:00
parent 16c2e40242
commit 51697c3aa4
10 changed files with 689 additions and 22 deletions

View File

@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ScrollView, View, Alert, ActivityIndicator } from 'react-native';
import { ScrollView, Text, View, Alert, ActivityIndicator } from 'react-native';
import { useRouter } from 'expo-router';
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import { AppHeader } from '../../components/AppHeader';
import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard';
import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard';
@ -16,10 +17,12 @@ import { useCustomDomains } from '../../hooks/useCustomDomains';
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
import { protection } from '../../lib/protection';
import { useColors } from '../../lib/theme';
export default function BlockerScreen() {
const router = useRouter();
const { t } = useTranslation();
const colors = useColors();
// react-native-bottom-tabs Tab-Bar ist iOS-nativ + translucent → unsere Content-View
// erstreckt sich UNTER den Tab-Bar. Ohne diese Höhe würden FAB + Bottom-Padding
// hinterm Tab-Bar verschwinden.
@ -258,6 +261,49 @@ export default function BlockerScreen() {
/>
)}
{/* Free: Erwartungs-Transparenz-Hinweis */}
{plan === 'free' && (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: colors.border,
flexDirection: 'row',
alignItems: 'flex-start',
gap: 8,
}}
>
<Ionicons name="shield-outline" size={15} color={colors.textMuted} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 17 }}>
{t('plan_limit.blocker_basic_protection')}
</Text>
</View>
)}
{/* Über-Limit: Custom-Domain-Banner */}
{tier.atLimit && tier.usedSlots > tier.domainLimit && (
<View
style={{
backgroundColor: 'rgba(217,119,6,0.08)',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: 'rgba(217,119,6,0.2)',
gap: 4,
}}
>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#d97706' }}>
{t('plan_limit.blocker_domain_over_limit', {
used: tier.usedSlots,
plan: plan.charAt(0).toUpperCase() + plan.slice(1),
max: tier.domainLimit,
})}
</Text>
</View>
)}
{/* Domain Grid mit inline + Button neben SlotPill */}
<View style={{ marginTop: 8 }}>
<DomainGrid

View File

@ -22,6 +22,60 @@ import { useMailDisconnect } from '../../hooks/useMailDisconnect';
import { useUserPlan } from '../../hooks/useUserPlan';
import { useColors } from '../../lib/theme';
const PLAN_LABEL: Record<string, string> = { free: 'Free', pro: 'Pro', legend: 'Legend' };
function MailOverLimitBanner({
usedCount,
maxAccounts,
planLabel,
pausedEmails,
colors,
}: {
usedCount: number;
maxAccounts: number;
planLabel: string;
pausedEmails: string[];
colors: import('../../lib/theme').ColorScheme;
}) {
const { t } = useTranslation();
const over = usedCount - maxAccounts;
if (over <= 0) return null;
return (
<View
style={{
backgroundColor: 'rgba(217,119,6,0.08)',
borderRadius: 14,
padding: 14,
marginBottom: 14,
borderWidth: 1,
borderColor: 'rgba(217,119,6,0.2)',
gap: 6,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Ionicons name="warning-outline" size={16} color="#d97706" />
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#d97706', flex: 1 }}>
{t('plan_limit.mail_banner_title')}
</Text>
</View>
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_400Regular', lineHeight: 18 }}>
{t(over === 1 ? 'plan_limit.mail_banner_body_one' : 'plan_limit.mail_banner_body_other', {
used: usedCount,
plan: planLabel,
max: maxAccounts,
over,
})}
</Text>
{pausedEmails.length > 0 && (
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{pausedEmails.join(', ')}
</Text>
)}
</View>
);
}
export default function MailScreen() {
const { t } = useTranslation();
const tabBarHeight = useBottomTabBarHeight();
@ -45,7 +99,9 @@ export default function MailScreen() {
.filter((v): v is string => v !== null)
.sort()[0] ?? null;
const limitReached = accounts.length >= maxAccounts;
const pausedAccounts = accounts.filter((a) => a.paused === true);
const overLimit = maxAccounts !== Infinity && accounts.length > maxAccounts;
const limitReached = maxAccounts !== Infinity && accounts.length >= maxAccounts;
function handleAddPress() {
if (limitReached) {
@ -95,6 +151,17 @@ export default function MailScreen() {
}}
showsVerticalScrollIndicator={false}
>
{/* Über-Limit-Banner: nur wenn Backend paused-Feld liefert + over limit */}
{overLimit && pausedAccounts.length > 0 && (
<MailOverLimitBanner
usedCount={accounts.length}
maxAccounts={maxAccounts}
planLabel={PLAN_LABEL[plan] ?? plan}
pausedEmails={pausedAccounts.map((a) => a.email)}
colors={colors}
/>
)}
{/* Stats card */}
{accounts.length > 0 && (
<View style={{ marginBottom: 14 }}>

View File

@ -6,6 +6,7 @@ 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();
@ -132,9 +133,9 @@ function PlanOverrideToggle({
currentPlan: Plan;
}) {
const [loading, setLoading] = useState(false);
const [sheetTarget, setSheetTarget] = useState<Plan | null>(null);
async function switchPlan(plan: Plan) {
if (plan === currentPlan) return;
async function applyPlanSwitch(plan: Plan) {
setLoading(true);
try {
await apiFetch('/api/dev/set-plan', {
@ -149,6 +150,11 @@ function PlanOverrideToggle({
}
}
function switchPlan(plan: Plan) {
if (plan === currentPlan) return;
setSheetTarget(plan);
}
return (
<View
style={{
@ -224,6 +230,19 @@ function PlanOverrideToggle({
);
})}
</View>
{sheetTarget ? (
<PlanChangeSheet
visible={sheetTarget !== null}
targetPlan={sheetTarget}
onConfirm={() => {
const t = sheetTarget;
setSheetTarget(null);
applyPlanSwitch(t);
}}
onClose={() => setSheetTarget(null)}
/>
) : null}
</View>
);
}

View File

@ -64,7 +64,7 @@ function StatusBadge({ status }: { status: ProtectedDevice['status'] }) {
const { t } = useTranslation();
const colors = useColors();
const config = {
const config: Record<string, { label: string; bg: string; fg: string }> = {
pending: {
label: t('devices.status_pending'),
bg: 'rgba(245,158,11,0.12)',
@ -80,11 +80,13 @@ function StatusBadge({ status }: { status: ProtectedDevice['status'] }) {
bg: 'rgba(0,0,0,0.06)',
fg: colors.textMuted,
},
}[status] ?? {
label: status,
bg: 'rgba(0,0,0,0.06)',
fg: colors.textMuted,
degraded: {
label: t('plan_limit.device_degraded_title'),
bg: 'rgba(220,38,38,0.1)',
fg: colors.error,
},
};
const resolved = config[status] ?? { label: status, bg: 'rgba(0,0,0,0.06)', fg: colors.textMuted };
return (
<View
@ -92,11 +94,11 @@ function StatusBadge({ status }: { status: ProtectedDevice['status'] }) {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 6,
backgroundColor: config.bg,
backgroundColor: resolved.bg,
}}
>
<Text style={{ fontSize: 10, color: config.fg, fontFamily: 'Nunito_600SemiBold' }}>
{config.label}
<Text style={{ fontSize: 10, color: resolved.fg, fontFamily: 'Nunito_600SemiBold' }}>
{resolved.label}
</Text>
</View>
);
@ -315,6 +317,20 @@ function ProtectedDeviceRow({
{t('settings.devices_since')} {formatSince(device.createdAt)}
</Text>
</View>
{device.status === 'degraded' && (
<Text
style={{
fontSize: 11,
color: colors.error,
fontFamily: 'Nunito_400Regular',
marginTop: 4,
lineHeight: 15,
}}
>
{t('plan_limit.device_degraded_body')}
</Text>
)}
</View>
<MenuView
@ -406,6 +422,10 @@ export default function DevicesScreen() {
loadProtected();
}, []);
const MAX_PROTECTED_DEVICES = 2;
const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length;
const atDeviceLimit = isLegend && activeProtectedCount >= MAX_PROTECTED_DEVICES;
const currentDevice = mobileDevices.find((d) => d.isCurrent);
const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_free');
@ -517,11 +537,36 @@ export default function DevicesScreen() {
{/* CTA or Upgrade */}
{isLegend ? (
<View style={{ gap: 10 }}>
{atDeviceLimit && (
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: colors.border,
flexDirection: 'row',
gap: 8,
alignItems: 'flex-start',
}}
>
<Ionicons name="information-circle-outline" size={16} color={colors.textMuted} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 13, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 18 }}>
{t('plan_limit.device_add_limit_hint', { max: MAX_PROTECTED_DEVICES })}
</Text>
</View>
)}
<TouchableOpacity
onPress={() => setAddMacVisible(true)}
onPress={() => {
if (atDeviceLimit) {
Alert.alert(t('plan_limit.device_add_limit_short'), t('plan_limit.device_add_limit_hint', { max: MAX_PROTECTED_DEVICES }));
return;
}
setAddMacVisible(true);
}}
activeOpacity={0.7}
style={{
backgroundColor: colors.brandOrange,
backgroundColor: atDeviceLimit ? colors.surfaceElevated : colors.brandOrange,
borderRadius: 14,
paddingVertical: 16,
alignItems: 'center',
@ -530,14 +575,20 @@ export default function DevicesScreen() {
gap: 8,
}}
>
<Ionicons name="add-circle-outline" size={20} color="#fff" />
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
<Ionicons name="add-circle-outline" size={20} color={atDeviceLimit ? colors.textMuted : '#fff'} />
<Text style={{ fontSize: 16, color: atDeviceLimit ? colors.textMuted : '#fff', fontFamily: 'Nunito_700Bold' }}>
{t('devices.add_mac')}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setAddWindowsVisible(true)}
onPress={() => {
if (atDeviceLimit) {
Alert.alert(t('plan_limit.device_add_limit_short'), t('plan_limit.device_add_limit_hint', { max: MAX_PROTECTED_DEVICES }));
return;
}
setAddWindowsVisible(true);
}}
activeOpacity={0.7}
style={{
borderRadius: 14,

View File

@ -31,6 +31,25 @@ type Props = {
disconnecting?: boolean;
};
function PausedBadge({ t }: { t: (k: string) => string }) {
return (
<View
style={{
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 5,
backgroundColor: 'rgba(115,115,115,0.1)',
alignSelf: 'flex-start',
marginTop: 3,
}}
>
<Text style={{ fontSize: 10, color: '#737373', fontFamily: 'Nunito_600SemiBold' }}>
{t('plan_limit.mail_account_paused')}
</Text>
</View>
);
}
function resolveProviderIcon(provider: string): {
icon: React.ComponentProps<typeof Ionicons>['name'];
color: string;
@ -267,6 +286,7 @@ export function MailAccountCard({
const { icon, color } = resolveProviderIcon(account.provider);
const isLegend = plan === 'legend';
const isPaused = account.paused === true;
const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan];
function handleToggle() {
@ -287,11 +307,12 @@ export function MailAccountCard({
<>
<View
style={{
backgroundColor: '#fff',
backgroundColor: isPaused ? '#fafafa' : '#fff',
borderRadius: 16,
borderWidth: 1,
borderColor: account.lastConnectError ? '#fecaca' : '#e5e5e5',
borderColor: account.lastConnectError ? '#fecaca' : isPaused ? '#d4d4d4' : '#e5e5e5',
overflow: 'hidden',
opacity: isPaused ? 0.75 : 1,
}}
>
{/* Header */}
@ -313,12 +334,15 @@ export function MailAccountCard({
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
<Text
style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: isPaused ? '#a3a3a3' : '#0a0a0a' }}
numberOfLines={1}
>
{account.email}
</Text>
<StatusBadgeRow account={account} isLegend={isLegend} t={t} />
{isPaused
? <PausedBadge t={t} />
: <StatusBadgeRow account={account} isLegend={isLegend} t={t} />
}
</View>
<Ionicons

View File

@ -0,0 +1,381 @@
import { useEffect, useRef, useState } from 'react';
import {
Animated,
Dimensions,
Modal,
ScrollView,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../../lib/api';
import { useColors } from '../../lib/theme';
import type { Plan } from '../../hooks/useMe';
export type ChangePreviewItem = {
resource: string;
current: number | string;
newLimit: number | string;
overBy: number;
action: 'keep' | 'limited' | 'paused' | 'grace_then_off' | 'degraded' | 'unlocked';
detail: string;
graceUntilDays?: number;
};
export type ChangePreview = {
from: Plan;
to: Plan;
direction: 'upgrade' | 'downgrade' | 'same';
gains: string[];
keeps: string[];
changes: ChangePreviewItem[];
};
type Props = {
visible: boolean;
targetPlan: Plan;
onConfirm: () => void;
onClose: () => void;
};
const SCREEN_HEIGHT = Dimensions.get('window').height;
const PLAN_LABEL: Record<Plan, string> = {
free: 'Free',
pro: 'Pro',
legend: 'Legend',
};
function ActionChip({ action }: { action: ChangePreviewItem['action'] }) {
const { t } = useTranslation();
const configs: Record<ChangePreviewItem['action'], { label: string; fg: string; bg: string }> = {
keep: { label: t('plan.change.action_keep'), fg: '#16a34a', bg: 'rgba(22,163,74,0.1)' },
limited: { label: t('plan.change.action_limited'), fg: '#d97706', bg: 'rgba(217,119,6,0.1)' },
paused: { label: t('plan.change.action_paused'), fg: '#737373', bg: 'rgba(115,115,115,0.1)' },
grace_then_off:{ label: t('plan.change.action_grace'), fg: '#d97706', bg: 'rgba(217,119,6,0.1)' },
degraded: { label: t('plan.change.action_degraded'), fg: '#dc2626', bg: 'rgba(220,38,38,0.1)' },
unlocked: { label: t('plan.change.action_unlocked'), fg: '#16a34a', bg: 'rgba(22,163,74,0.1)' },
};
const c = configs[action];
return (
<View style={{ paddingHorizontal: 6, paddingVertical: 2, borderRadius: 5, backgroundColor: c.bg, alignSelf: 'flex-start' }}>
<Text style={{ fontSize: 10, fontFamily: 'Nunito_600SemiBold', color: c.fg }}>{c.label}</Text>
</View>
);
}
export function PlanChangeSheet({ visible, targetPlan, onConfirm, onClose }: Props) {
const { t } = useTranslation();
const colors = useColors();
const insets = useSafeAreaInsets();
const translateY = useRef(new Animated.Value(SCREEN_HEIGHT)).current;
const [preview, setPreview] = useState<ChangePreview | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!visible) return;
setPreview(null);
setError(null);
setLoading(true);
apiFetch<ChangePreview>(`/api/plan/change-preview?to=${targetPlan}`)
.then(setPreview)
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false));
}, [visible, targetPlan]);
useEffect(() => {
Animated.timing(translateY, {
toValue: visible ? 0 : SCREEN_HEIGHT,
duration: 320,
useNativeDriver: true,
}).start();
}, [visible, translateY]);
const isDowngrade = preview?.direction === 'downgrade';
return (
<Modal transparent visible={visible} animationType="none" onRequestClose={onClose}>
<View style={{ flex: 1, justifyContent: 'flex-end' }}>
<Animated.View
style={{
backgroundColor: colors.bg,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: SCREEN_HEIGHT * 0.88,
transform: [{ translateY }],
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.08,
shadowRadius: 16,
elevation: 12,
}}
>
{/* Handle */}
<View style={{ alignItems: 'center', paddingTop: 10, paddingBottom: 4 }}>
<View style={{ width: 40, height: 4, borderRadius: 2, backgroundColor: colors.border }} />
</View>
<ScrollView
contentContainerStyle={{
paddingHorizontal: 20,
paddingBottom: insets.bottom + 24,
paddingTop: 8,
gap: 20,
}}
showsVerticalScrollIndicator={false}
>
{loading ? (
<View style={{ paddingVertical: 48, alignItems: 'center' }}>
<Text style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{t('common.loading')}
</Text>
</View>
) : error ? (
<View style={{ paddingVertical: 32, alignItems: 'center', gap: 12 }}>
<Text style={{ fontSize: 14, color: colors.error, fontFamily: 'Nunito_400Regular', textAlign: 'center' }}>
{error}
</Text>
<TouchableOpacity
activeOpacity={0.7}
onPress={onClose}
style={{ paddingVertical: 12, paddingHorizontal: 24, backgroundColor: colors.surfaceElevated, borderRadius: 12 }}
>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
{t('common.back')}
</Text>
</TouchableOpacity>
</View>
) : preview ? (
<SheetContent
preview={preview}
targetPlan={targetPlan}
isDowngrade={isDowngrade}
colors={colors}
onConfirm={onConfirm}
onClose={onClose}
t={t}
/>
) : null}
</ScrollView>
</Animated.View>
</View>
</Modal>
);
}
type ContentProps = {
preview: ChangePreview;
targetPlan: Plan;
isDowngrade: boolean;
colors: import('../../lib/theme').ColorScheme;
onConfirm: () => void;
onClose: () => void;
t: (k: string, opts?: Record<string, string | number>) => string;
};
function SheetContent({ preview, targetPlan, isDowngrade, colors, onConfirm, onClose, t }: ContentProps) {
const fromLabel = PLAN_LABEL[preview.from];
const toLabel = PLAN_LABEL[targetPlan];
return (
<>
{/* Header */}
<Text style={{ fontSize: 22, fontFamily: 'Nunito_700Bold', color: colors.text, lineHeight: 28 }}>
{isDowngrade
? t('plan.change.header_downgrade', { from: fromLabel, to: toLabel })
: t('plan.change.header_upgrade', { to: toLabel })}
</Text>
{/* Downgrade: Beruhigung zuerst */}
{isDowngrade && (
<View style={{ gap: 10 }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
{t('plan.change.downgrade_reassurance')}
</Text>
{preview.keeps.length > 0 && (
<View style={{ gap: 6 }}>
{preview.keeps.map((k, i) => (
<View key={i} style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 8 }}>
<Ionicons name="checkmark-circle-outline" size={16} color={colors.success} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 14, color: colors.text, fontFamily: 'Nunito_400Regular', lineHeight: 20 }}>
{k}
</Text>
</View>
))}
</View>
)}
</View>
)}
{/* Upgrade: gains */}
{!isDowngrade && preview.gains.length > 0 && (
<View style={{ gap: 8 }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_700Bold', color: colors.textMuted, textTransform: 'uppercase', letterSpacing: 0.8 }}>
{t('plan.change.section_gains')}
</Text>
<View style={{ gap: 6 }}>
{preview.gains.map((g, i) => (
<View key={i} style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 8 }}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 14, color: colors.text, fontFamily: 'Nunito_400Regular', lineHeight: 20 }}>
{g}
</Text>
</View>
))}
</View>
</View>
)}
{/* Upgrade: keeps */}
{!isDowngrade && preview.keeps.length > 0 && (
<View style={{ gap: 8 }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_700Bold', color: colors.textMuted, textTransform: 'uppercase', letterSpacing: 0.8 }}>
{t('plan.change.section_keeps')}
</Text>
<View style={{ gap: 6 }}>
{preview.keeps.map((k, i) => (
<View key={i} style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 8 }}>
<Ionicons name="checkmark-outline" size={16} color={colors.textMuted} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 20 }}>
{k}
</Text>
</View>
))}
</View>
</View>
)}
{/* Downgrade: changes list */}
{isDowngrade && preview.changes.length > 0 && (
<View style={{ gap: 10 }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_700Bold', color: colors.textMuted, textTransform: 'uppercase', letterSpacing: 0.8 }}>
{t('plan.change.section_changes')}
</Text>
<View style={{ gap: 8 }}>
{preview.changes.map((c, i) => (
<View
key={i}
style={{
backgroundColor: colors.surface,
borderRadius: 12,
padding: 12,
gap: 6,
borderWidth: 1,
borderColor: colors.border,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: colors.text, flex: 1 }}>
{c.resource}
</Text>
<ActionChip action={c.action} />
</View>
<Text style={{ fontSize: 13, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 18 }}>
{c.detail}
</Text>
{c.graceUntilDays != null && c.graceUntilDays > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<Ionicons name="time-outline" size={12} color={colors.warning} />
<Text style={{ fontSize: 12, color: colors.warning, fontFamily: 'Nunito_600SemiBold' }}>
{t('plan.change.grace_days', { count: c.graceUntilDays })}
</Text>
</View>
)}
</View>
))}
</View>
</View>
)}
{/* Downgrade: was nicht passiert */}
{isDowngrade && (
<View
style={{
backgroundColor: 'rgba(22,163,74,0.06)',
borderRadius: 12,
padding: 14,
gap: 4,
borderWidth: 1,
borderColor: 'rgba(22,163,74,0.15)',
}}
>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.success }}>
{t('plan.change.downgrade_no_delete_title')}
</Text>
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_400Regular', lineHeight: 18 }}>
{t('plan.change.downgrade_no_delete_body')}
</Text>
</View>
)}
{/* Upgrade: Abrechnungs-Hinweis statt Kauf-Button */}
{!isDowngrade && (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 12,
padding: 14,
borderWidth: 1,
borderColor: colors.border,
}}
>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 17 }}>
{t('plan.change.billing_hint')}
</Text>
</View>
)}
{/* Downgrade: Recovery-Sicherheitssatz */}
{isDowngrade && (
<Text style={{ fontSize: 13, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 19, fontStyle: 'italic' }}>
{t('plan.change.downgrade_recovery_note')}
</Text>
)}
{/* CTAs */}
<View style={{ gap: 10, marginTop: 4 }}>
<TouchableOpacity
activeOpacity={0.7}
onPress={onConfirm}
style={{
backgroundColor: '#007AFF',
borderRadius: 14,
paddingVertical: 16,
alignItems: 'center',
}}
>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#ffffff' }}>
{isDowngrade ? t('plan.change.cta_confirm_downgrade') : t('plan.change.cta_confirm_upgrade')}
</Text>
</TouchableOpacity>
{isDowngrade && (
<TouchableOpacity
activeOpacity={0.7}
onPress={onClose}
style={{ paddingVertical: 12, alignItems: 'center' }}
>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('plan.change.cta_stay', { plan: PLAN_LABEL[preview.from] })}
</Text>
</TouchableOpacity>
)}
{!isDowngrade && (
<TouchableOpacity
activeOpacity={0.7}
onPress={onClose}
style={{ paddingVertical: 12, alignItems: 'center' }}
>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('common.cancel')}
</Text>
</TouchableOpacity>
)}
</View>
</>
);
}

View File

@ -7,6 +7,7 @@ export type MailAccount = {
email: string;
provider: string;
isActive: boolean;
paused?: boolean;
lastScannedAt: string | null;
nextScanAt: string | null;
totalBlocked: number;

View File

@ -779,6 +779,45 @@
"windows_success_body": "Du kannst weitere Geräte hinzufügen wenn du willst.",
"windows_remove_warning_body": "Wir können die Registrierung nicht aus der Ferne löschen. Auf dem PC: Regedit → HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\DoHSvc → Schlüssel entfernen."
},
"plan": {
"change": {
"header_upgrade": "Du wechselst auf {{to}}.",
"header_downgrade": "Du wechselst von {{from}} auf {{to}}.",
"section_gains": "Was du dazubekommst",
"section_keeps": "Was gleich bleibt",
"section_changes": "Was sich ändert",
"downgrade_reassurance": "Dein Grundschutz läuft weiter.",
"downgrade_no_delete_title": "Es wird nichts gelöscht.",
"downgrade_no_delete_body": "Alles Pausierte kommt sofort zurück, wenn du wieder upgradest.",
"downgrade_recovery_note": "Wenn dieser Wechsel deinen Schutz in einem Moment schwächt, in dem du dir unsicher bist — schreib Lyra. Oder schreib uns. Wir finden eine Lösung.",
"billing_hint": "Verwalte dein Abo auf rebreak.org.",
"grace_days_one": "läuft in {{count}} Tag aus",
"grace_days_other": "läuft in {{count}} Tagen aus",
"cta_confirm_upgrade": "Los geht's",
"cta_confirm_downgrade": "Verstanden, weiter",
"cta_stay": "Doch bei {{plan}} bleiben",
"action_keep": "bleibt",
"action_limited": "wird limitiert",
"action_paused": "wird pausiert",
"action_grace": "Grace-Period",
"action_degraded": "Schutz läuft aus",
"action_unlocked": "freigeschaltet"
}
},
"plan_limit": {
"mail_banner_title": "Postfächer über Plan-Limit",
"mail_banner_body_one": "Du hast {{used}} Postfach, {{plan}} schützt {{max}} — {{over}} ist pausiert.",
"mail_banner_body_other": "Du hast {{used}} Postfächer, {{plan}} schützt {{max}} — {{over}} sind pausiert.",
"mail_account_paused": "Pausiert (Plan-Downgrade)",
"mail_add_disabled_hint": "Erst ein Postfach pausieren oder upgraden.",
"blocker_domain_over_limit": "Du hast {{used}} eigene Domains, {{plan}} erlaubt {{max}} — alle bleiben aktiv, du kannst keine neue hinzufügen bis du unter {{max}} bist.",
"blocker_add_disabled_hint": "Erst eine Domain entfernen oder upgraden.",
"blocker_basic_protection": "Grundschutz aktiv — voller Schutz vor allen bekannten Glücksspiel-Seiten: Pro/Legend.",
"device_degraded_title": "Schutz ausgelaufen",
"device_degraded_body": "Das Profil ist noch auf dem Gerät installiert. Entferne es manuell oder hol dir Legend zurück.",
"device_add_limit_hint": "Du hast alle {{max}} Geräteslots belegt. Entferne ein Gerät oder upgraden.",
"device_add_limit_short": "Limit erreicht"
},
"gameOver": {
"title": "Spiel beendet",
"score": "Score",

View File

@ -779,6 +779,45 @@
"windows_success_body": "You can add more devices whenever you like.",
"windows_remove_warning_body": "We can't delete the registry entry remotely. On the PC: Regedit → HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\DoHSvc → remove the key."
},
"plan": {
"change": {
"header_upgrade": "You're switching to {{to}}.",
"header_downgrade": "You're switching from {{from}} to {{to}}.",
"section_gains": "What you're getting",
"section_keeps": "What stays the same",
"section_changes": "What changes",
"downgrade_reassurance": "Your core protection keeps running.",
"downgrade_no_delete_title": "Nothing gets deleted.",
"downgrade_no_delete_body": "Everything paused comes back immediately when you upgrade again.",
"downgrade_recovery_note": "If this change weakens your protection at a moment when you feel uncertain — write to Lyra. Or write to us. We'll find a solution.",
"billing_hint": "Manage your subscription at rebreak.org.",
"grace_days_one": "expires in {{count}} day",
"grace_days_other": "expires in {{count}} days",
"cta_confirm_upgrade": "Let's go",
"cta_confirm_downgrade": "Got it, continue",
"cta_stay": "Stay on {{plan}}",
"action_keep": "stays",
"action_limited": "will be limited",
"action_paused": "will be paused",
"action_grace": "Grace period",
"action_degraded": "protection ending",
"action_unlocked": "unlocked"
}
},
"plan_limit": {
"mail_banner_title": "Mailboxes over plan limit",
"mail_banner_body_one": "You have {{used}} mailbox, {{plan}} protects {{max}} — {{over}} is paused.",
"mail_banner_body_other": "You have {{used}} mailboxes, {{plan}} protects {{max}} — {{over}} are paused.",
"mail_account_paused": "Paused (plan downgrade)",
"mail_add_disabled_hint": "Remove a mailbox first or upgrade.",
"blocker_domain_over_limit": "You have {{used}} custom domains, {{plan}} allows {{max}} — all stay active, you can't add new ones until you're under {{max}}.",
"blocker_add_disabled_hint": "Remove a domain first or upgrade.",
"blocker_basic_protection": "Basic protection active — full protection against all known gambling sites: Pro/Legend.",
"device_degraded_title": "Protection expired",
"device_degraded_body": "The profile is still installed on the device. Remove it manually or get Legend back.",
"device_add_limit_hint": "All {{max}} device slots are used. Remove a device or upgrade.",
"device_add_limit_short": "Limit reached"
},
"gameOver": {
"title": "Game over",
"score": "Score",

View File

@ -1,7 +1,7 @@
import { create } from 'zustand';
import { apiFetch } from '../lib/api';
export type ProtectedDeviceStatus = 'pending' | 'active' | 'revoked';
export type ProtectedDeviceStatus = 'pending' | 'active' | 'revoked' | 'degraded';
export interface ProtectedDevice {
id: string;