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:
parent
16c2e40242
commit
51697c3aa4
@ -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
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
381
apps/rebreak-native/components/plan/PlanChangeSheet.tsx
Normal file
381
apps/rebreak-native/components/plan/PlanChangeSheet.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -7,6 +7,7 @@ export type MailAccount = {
|
||||
email: string;
|
||||
provider: string;
|
||||
isActive: boolean;
|
||||
paused?: boolean;
|
||||
lastScannedAt: string | null;
|
||||
nextScanAt: string | null;
|
||||
totalBlocked: number;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user