diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx
index 7d267a4..f5e9630 100644
--- a/apps/rebreak-native/app/(app)/blocker.tsx
+++ b/apps/rebreak-native/app/(app)/blocker.tsx
@@ -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' && (
+
+
+
+ {t('plan_limit.blocker_basic_protection')}
+
+
+ )}
+
+ {/* Über-Limit: Custom-Domain-Banner */}
+ {tier.atLimit && tier.usedSlots > tier.domainLimit && (
+
+
+ {t('plan_limit.blocker_domain_over_limit', {
+ used: tier.usedSlots,
+ plan: plan.charAt(0).toUpperCase() + plan.slice(1),
+ max: tier.domainLimit,
+ })}
+
+
+ )}
+
{/* Domain Grid mit inline + Button neben SlotPill */}
= { 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 (
+
+
+
+
+ {t('plan_limit.mail_banner_title')}
+
+
+
+ {t(over === 1 ? 'plan_limit.mail_banner_body_one' : 'plan_limit.mail_banner_body_other', {
+ used: usedCount,
+ plan: planLabel,
+ max: maxAccounts,
+ over,
+ })}
+
+ {pausedEmails.length > 0 && (
+
+ {pausedEmails.join(', ')}
+
+ )}
+
+ );
+}
+
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 && (
+ a.email)}
+ colors={colors}
+ />
+ )}
+
{/* Stats card */}
{accounts.length > 0 && (
diff --git a/apps/rebreak-native/app/debug.tsx b/apps/rebreak-native/app/debug.tsx
index 05f8b42..ff25b2d 100644
--- a/apps/rebreak-native/app/debug.tsx
+++ b/apps/rebreak-native/app/debug.tsx
@@ -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(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 (
+
+ {sheetTarget ? (
+ {
+ const t = sheetTarget;
+ setSheetTarget(null);
+ applyPlanSwitch(t);
+ }}
+ onClose={() => setSheetTarget(null)}
+ />
+ ) : null}
);
}
diff --git a/apps/rebreak-native/app/devices.tsx b/apps/rebreak-native/app/devices.tsx
index 9e8a7ef..d18c61e 100644
--- a/apps/rebreak-native/app/devices.tsx
+++ b/apps/rebreak-native/app/devices.tsx
@@ -64,7 +64,7 @@ function StatusBadge({ status }: { status: ProtectedDevice['status'] }) {
const { t } = useTranslation();
const colors = useColors();
- const config = {
+ const config: Record = {
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 (
-
- {config.label}
+
+ {resolved.label}
);
@@ -315,6 +317,20 @@ function ProtectedDeviceRow({
{t('settings.devices_since')} {formatSince(device.createdAt)}
+
+ {device.status === 'degraded' && (
+
+ {t('plan_limit.device_degraded_body')}
+
+ )}
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 ? (
+ {atDeviceLimit && (
+
+
+
+ {t('plan_limit.device_add_limit_hint', { max: MAX_PROTECTED_DEVICES })}
+
+
+ )}
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,
}}
>
-
-
+
+
{t('devices.add_mac')}
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,
diff --git a/apps/rebreak-native/components/mail/MailAccountCard.tsx b/apps/rebreak-native/components/mail/MailAccountCard.tsx
index 4058674..fcda189 100644
--- a/apps/rebreak-native/components/mail/MailAccountCard.tsx
+++ b/apps/rebreak-native/components/mail/MailAccountCard.tsx
@@ -31,6 +31,25 @@ type Props = {
disconnecting?: boolean;
};
+function PausedBadge({ t }: { t: (k: string) => string }) {
+ return (
+
+
+ {t('plan_limit.mail_account_paused')}
+
+
+ );
+}
+
function resolveProviderIcon(provider: string): {
icon: React.ComponentProps['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({
<>
{/* Header */}
@@ -313,12 +334,15 @@ export function MailAccountCard({
{account.email}
-
+ {isPaused
+ ?
+ :
+ }
void;
+ onClose: () => void;
+};
+
+const SCREEN_HEIGHT = Dimensions.get('window').height;
+const PLAN_LABEL: Record = {
+ free: 'Free',
+ pro: 'Pro',
+ legend: 'Legend',
+};
+
+function ActionChip({ action }: { action: ChangePreviewItem['action'] }) {
+ const { t } = useTranslation();
+ const configs: Record = {
+ 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 (
+
+ {c.label}
+
+ );
+}
+
+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(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!visible) return;
+ setPreview(null);
+ setError(null);
+ setLoading(true);
+ apiFetch(`/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 (
+
+
+
+ {/* Handle */}
+
+
+
+
+
+ {loading ? (
+
+
+ {t('common.loading')}
+
+
+ ) : error ? (
+
+
+ {error}
+
+
+
+ {t('common.back')}
+
+
+
+ ) : preview ? (
+
+ ) : null}
+
+
+
+
+ );
+}
+
+type ContentProps = {
+ preview: ChangePreview;
+ targetPlan: Plan;
+ isDowngrade: boolean;
+ colors: import('../../lib/theme').ColorScheme;
+ onConfirm: () => void;
+ onClose: () => void;
+ t: (k: string, opts?: Record) => string;
+};
+
+function SheetContent({ preview, targetPlan, isDowngrade, colors, onConfirm, onClose, t }: ContentProps) {
+ const fromLabel = PLAN_LABEL[preview.from];
+ const toLabel = PLAN_LABEL[targetPlan];
+
+ return (
+ <>
+ {/* Header */}
+
+ {isDowngrade
+ ? t('plan.change.header_downgrade', { from: fromLabel, to: toLabel })
+ : t('plan.change.header_upgrade', { to: toLabel })}
+
+
+ {/* Downgrade: Beruhigung zuerst */}
+ {isDowngrade && (
+
+
+ {t('plan.change.downgrade_reassurance')}
+
+ {preview.keeps.length > 0 && (
+
+ {preview.keeps.map((k, i) => (
+
+
+
+ {k}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Upgrade: gains */}
+ {!isDowngrade && preview.gains.length > 0 && (
+
+
+ {t('plan.change.section_gains')}
+
+
+ {preview.gains.map((g, i) => (
+
+
+
+ {g}
+
+
+ ))}
+
+
+ )}
+
+ {/* Upgrade: keeps */}
+ {!isDowngrade && preview.keeps.length > 0 && (
+
+
+ {t('plan.change.section_keeps')}
+
+
+ {preview.keeps.map((k, i) => (
+
+
+
+ {k}
+
+
+ ))}
+
+
+ )}
+
+ {/* Downgrade: changes list */}
+ {isDowngrade && preview.changes.length > 0 && (
+
+
+ {t('plan.change.section_changes')}
+
+
+ {preview.changes.map((c, i) => (
+
+
+
+ {c.resource}
+
+
+
+
+ {c.detail}
+
+ {c.graceUntilDays != null && c.graceUntilDays > 0 && (
+
+
+
+ {t('plan.change.grace_days', { count: c.graceUntilDays })}
+
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Downgrade: was nicht passiert */}
+ {isDowngrade && (
+
+
+ {t('plan.change.downgrade_no_delete_title')}
+
+
+ {t('plan.change.downgrade_no_delete_body')}
+
+
+ )}
+
+ {/* Upgrade: Abrechnungs-Hinweis statt Kauf-Button */}
+ {!isDowngrade && (
+
+
+ {t('plan.change.billing_hint')}
+
+
+ )}
+
+ {/* Downgrade: Recovery-Sicherheitssatz */}
+ {isDowngrade && (
+
+ {t('plan.change.downgrade_recovery_note')}
+
+ )}
+
+ {/* CTAs */}
+
+
+
+ {isDowngrade ? t('plan.change.cta_confirm_downgrade') : t('plan.change.cta_confirm_upgrade')}
+
+
+
+ {isDowngrade && (
+
+
+ {t('plan.change.cta_stay', { plan: PLAN_LABEL[preview.from] })}
+
+
+ )}
+
+ {!isDowngrade && (
+
+
+ {t('common.cancel')}
+
+
+ )}
+
+ >
+ );
+}
diff --git a/apps/rebreak-native/hooks/useMailStatus.ts b/apps/rebreak-native/hooks/useMailStatus.ts
index 2536986..a049cc0 100644
--- a/apps/rebreak-native/hooks/useMailStatus.ts
+++ b/apps/rebreak-native/hooks/useMailStatus.ts
@@ -7,6 +7,7 @@ export type MailAccount = {
email: string;
provider: string;
isActive: boolean;
+ paused?: boolean;
lastScannedAt: string | null;
nextScanAt: string | null;
totalBlocked: number;
diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json
index f51ec8d..38461d2 100644
--- a/apps/rebreak-native/locales/de.json
+++ b/apps/rebreak-native/locales/de.json
@@ -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",
diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json
index c6b292f..07604ab 100644
--- a/apps/rebreak-native/locales/en.json
+++ b/apps/rebreak-native/locales/en.json
@@ -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",
diff --git a/apps/rebreak-native/stores/protectedDevices.ts b/apps/rebreak-native/stores/protectedDevices.ts
index 5baac65..bc7a0ee 100644
--- a/apps/rebreak-native/stores/protectedDevices.ts
+++ b/apps/rebreak-native/stores/protectedDevices.ts
@@ -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;