chahinebrini 51697c3aa4 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>
2026-05-11 16:21:47 +02:00

382 lines
14 KiB
TypeScript

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>
</>
);
}