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 { 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 { useRouter } from 'expo-router';
|
||||||
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
|
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { AppHeader } from '../../components/AppHeader';
|
import { AppHeader } from '../../components/AppHeader';
|
||||||
import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard';
|
import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard';
|
||||||
import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard';
|
import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard';
|
||||||
@ -16,10 +17,12 @@ import { useCustomDomains } from '../../hooks/useCustomDomains';
|
|||||||
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
|
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
|
||||||
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
|
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
|
||||||
import { protection } from '../../lib/protection';
|
import { protection } from '../../lib/protection';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
export default function BlockerScreen() {
|
export default function BlockerScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
// react-native-bottom-tabs Tab-Bar ist iOS-nativ + translucent → unsere Content-View
|
// 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
|
// erstreckt sich UNTER den Tab-Bar. Ohne diese Höhe würden FAB + Bottom-Padding
|
||||||
// hinterm Tab-Bar verschwinden.
|
// 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 */}
|
{/* Domain Grid mit inline + Button neben SlotPill */}
|
||||||
<View style={{ marginTop: 8 }}>
|
<View style={{ marginTop: 8 }}>
|
||||||
<DomainGrid
|
<DomainGrid
|
||||||
|
|||||||
@ -22,6 +22,60 @@ import { useMailDisconnect } from '../../hooks/useMailDisconnect';
|
|||||||
import { useUserPlan } from '../../hooks/useUserPlan';
|
import { useUserPlan } from '../../hooks/useUserPlan';
|
||||||
import { useColors } from '../../lib/theme';
|
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() {
|
export default function MailScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tabBarHeight = useBottomTabBarHeight();
|
const tabBarHeight = useBottomTabBarHeight();
|
||||||
@ -45,7 +99,9 @@ export default function MailScreen() {
|
|||||||
.filter((v): v is string => v !== null)
|
.filter((v): v is string => v !== null)
|
||||||
.sort()[0] ?? 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() {
|
function handleAddPress() {
|
||||||
if (limitReached) {
|
if (limitReached) {
|
||||||
@ -95,6 +151,17 @@ export default function MailScreen() {
|
|||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
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 */}
|
{/* Stats card */}
|
||||||
{accounts.length > 0 && (
|
{accounts.length > 0 && (
|
||||||
<View style={{ marginBottom: 14 }}>
|
<View style={{ marginBottom: 14 }}>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
import { useMe, invalidateMe, type Plan } from '../hooks/useMe';
|
import { useMe, invalidateMe, type Plan } from '../hooks/useMe';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
|
import { PlanChangeSheet } from '../components/plan/PlanChangeSheet';
|
||||||
|
|
||||||
export default function DebugScreen() {
|
export default function DebugScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -132,9 +133,9 @@ function PlanOverrideToggle({
|
|||||||
currentPlan: Plan;
|
currentPlan: Plan;
|
||||||
}) {
|
}) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sheetTarget, setSheetTarget] = useState<Plan | null>(null);
|
||||||
|
|
||||||
async function switchPlan(plan: Plan) {
|
async function applyPlanSwitch(plan: Plan) {
|
||||||
if (plan === currentPlan) return;
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await apiFetch('/api/dev/set-plan', {
|
await apiFetch('/api/dev/set-plan', {
|
||||||
@ -149,6 +150,11 @@ function PlanOverrideToggle({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function switchPlan(plan: Plan) {
|
||||||
|
if (plan === currentPlan) return;
|
||||||
|
setSheetTarget(plan);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -224,6 +230,19 @@ function PlanOverrideToggle({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{sheetTarget ? (
|
||||||
|
<PlanChangeSheet
|
||||||
|
visible={sheetTarget !== null}
|
||||||
|
targetPlan={sheetTarget}
|
||||||
|
onConfirm={() => {
|
||||||
|
const t = sheetTarget;
|
||||||
|
setSheetTarget(null);
|
||||||
|
applyPlanSwitch(t);
|
||||||
|
}}
|
||||||
|
onClose={() => setSheetTarget(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,7 +64,7 @@ function StatusBadge({ status }: { status: ProtectedDevice['status'] }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
|
||||||
const config = {
|
const config: Record<string, { label: string; bg: string; fg: string }> = {
|
||||||
pending: {
|
pending: {
|
||||||
label: t('devices.status_pending'),
|
label: t('devices.status_pending'),
|
||||||
bg: 'rgba(245,158,11,0.12)',
|
bg: 'rgba(245,158,11,0.12)',
|
||||||
@ -80,11 +80,13 @@ function StatusBadge({ status }: { status: ProtectedDevice['status'] }) {
|
|||||||
bg: 'rgba(0,0,0,0.06)',
|
bg: 'rgba(0,0,0,0.06)',
|
||||||
fg: colors.textMuted,
|
fg: colors.textMuted,
|
||||||
},
|
},
|
||||||
}[status] ?? {
|
degraded: {
|
||||||
label: status,
|
label: t('plan_limit.device_degraded_title'),
|
||||||
bg: 'rgba(0,0,0,0.06)',
|
bg: 'rgba(220,38,38,0.1)',
|
||||||
fg: colors.textMuted,
|
fg: colors.error,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
const resolved = config[status] ?? { label: status, bg: 'rgba(0,0,0,0.06)', fg: colors.textMuted };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@ -92,11 +94,11 @@ function StatusBadge({ status }: { status: ProtectedDevice['status'] }) {
|
|||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
backgroundColor: config.bg,
|
backgroundColor: resolved.bg,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: 10, color: config.fg, fontFamily: 'Nunito_600SemiBold' }}>
|
<Text style={{ fontSize: 10, color: resolved.fg, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
{config.label}
|
{resolved.label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@ -315,6 +317,20 @@ function ProtectedDeviceRow({
|
|||||||
{t('settings.devices_since')} {formatSince(device.createdAt)}
|
{t('settings.devices_since')} {formatSince(device.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
|
|
||||||
<MenuView
|
<MenuView
|
||||||
@ -406,6 +422,10 @@ export default function DevicesScreen() {
|
|||||||
loadProtected();
|
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 currentDevice = mobileDevices.find((d) => d.isCurrent);
|
||||||
const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_free');
|
const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_free');
|
||||||
|
|
||||||
@ -517,11 +537,36 @@ export default function DevicesScreen() {
|
|||||||
{/* CTA or Upgrade */}
|
{/* CTA or Upgrade */}
|
||||||
{isLegend ? (
|
{isLegend ? (
|
||||||
<View style={{ gap: 10 }}>
|
<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
|
<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}
|
activeOpacity={0.7}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: colors.brandOrange,
|
backgroundColor: atDeviceLimit ? colors.surfaceElevated : colors.brandOrange,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
paddingVertical: 16,
|
paddingVertical: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -530,14 +575,20 @@ export default function DevicesScreen() {
|
|||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="add-circle-outline" size={20} color="#fff" />
|
<Ionicons name="add-circle-outline" size={20} color={atDeviceLimit ? colors.textMuted : '#fff'} />
|
||||||
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
|
<Text style={{ fontSize: 16, color: atDeviceLimit ? colors.textMuted : '#fff', fontFamily: 'Nunito_700Bold' }}>
|
||||||
{t('devices.add_mac')}
|
{t('devices.add_mac')}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<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}
|
activeOpacity={0.7}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
|
|||||||
@ -31,6 +31,25 @@ type Props = {
|
|||||||
disconnecting?: boolean;
|
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): {
|
function resolveProviderIcon(provider: string): {
|
||||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||||
color: string;
|
color: string;
|
||||||
@ -267,6 +286,7 @@ export function MailAccountCard({
|
|||||||
const { icon, color } = resolveProviderIcon(account.provider);
|
const { icon, color } = resolveProviderIcon(account.provider);
|
||||||
|
|
||||||
const isLegend = plan === 'legend';
|
const isLegend = plan === 'legend';
|
||||||
|
const isPaused = account.paused === true;
|
||||||
const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan];
|
const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan];
|
||||||
|
|
||||||
function handleToggle() {
|
function handleToggle() {
|
||||||
@ -287,11 +307,12 @@ export function MailAccountCard({
|
|||||||
<>
|
<>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#fff',
|
backgroundColor: isPaused ? '#fafafa' : '#fff',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: account.lastConnectError ? '#fecaca' : '#e5e5e5',
|
borderColor: account.lastConnectError ? '#fecaca' : isPaused ? '#d4d4d4' : '#e5e5e5',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
opacity: isPaused ? 0.75 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -313,12 +334,15 @@ export function MailAccountCard({
|
|||||||
|
|
||||||
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
|
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
|
style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: isPaused ? '#a3a3a3' : '#0a0a0a' }}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{account.email}
|
{account.email}
|
||||||
</Text>
|
</Text>
|
||||||
<StatusBadgeRow account={account} isLegend={isLegend} t={t} />
|
{isPaused
|
||||||
|
? <PausedBadge t={t} />
|
||||||
|
: <StatusBadgeRow account={account} isLegend={isLegend} t={t} />
|
||||||
|
}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Ionicons
|
<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;
|
email: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
paused?: boolean;
|
||||||
lastScannedAt: string | null;
|
lastScannedAt: string | null;
|
||||||
nextScanAt: string | null;
|
nextScanAt: string | null;
|
||||||
totalBlocked: number;
|
totalBlocked: number;
|
||||||
|
|||||||
@ -779,6 +779,45 @@
|
|||||||
"windows_success_body": "Du kannst weitere Geräte hinzufügen wenn du willst.",
|
"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."
|
"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": {
|
"gameOver": {
|
||||||
"title": "Spiel beendet",
|
"title": "Spiel beendet",
|
||||||
"score": "Score",
|
"score": "Score",
|
||||||
|
|||||||
@ -779,6 +779,45 @@
|
|||||||
"windows_success_body": "You can add more devices whenever you like.",
|
"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."
|
"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": {
|
"gameOver": {
|
||||||
"title": "Game over",
|
"title": "Game over",
|
||||||
"score": "Score",
|
"score": "Score",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
export type ProtectedDeviceStatus = 'pending' | 'active' | 'revoked';
|
export type ProtectedDeviceStatus = 'pending' | 'active' | 'revoked' | 'degraded';
|
||||||
|
|
||||||
export interface ProtectedDevice {
|
export interface ProtectedDevice {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user