From 51697c3aa41b54bc9f588a4cdbc59ae7b534bccb Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 11 May 2026 16:21:47 +0200 Subject: [PATCH] feat(tier): plan-change briefing sheet + over-limit cards (Phase 2 UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/rebreak-native/app/(app)/blocker.tsx | 48 ++- apps/rebreak-native/app/(app)/mail.tsx | 69 +++- apps/rebreak-native/app/debug.tsx | 23 +- apps/rebreak-native/app/devices.tsx | 77 +++- .../components/mail/MailAccountCard.tsx | 32 +- .../components/plan/PlanChangeSheet.tsx | 381 ++++++++++++++++++ apps/rebreak-native/hooks/useMailStatus.ts | 1 + apps/rebreak-native/locales/de.json | 39 ++ apps/rebreak-native/locales/en.json | 39 ++ .../rebreak-native/stores/protectedDevices.ts | 2 +- 10 files changed, 689 insertions(+), 22 deletions(-) create mode 100644 apps/rebreak-native/components/plan/PlanChangeSheet.tsx 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;