Replaces ad-hoc TouchableOpacity+styled-Text pairs with a single `<Button>` covering the four variants we actually use (primary, secondary, ghost, destructive), with size (sm/md/lg), loading, disabled, icon, iconPosition, and a style escape hatch. Migrated files: AddMacSheet, AddWindowsSheet, PlanChangeSheet, devices.tsx CTA, settings SubscriptionSheet CTA. Skipped (kept as-is to avoid hostile overrides): auth flow buttons (Google/Apple OAuth with custom SVGs), list-row Touchables, blocker & mail components (separate sweep when those screens come up). paddingVertical default 12 (md) — matches the slimmer-buttons direction we landed on in the devices-page redesign.
361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import {
|
|
Animated,
|
|
Dimensions,
|
|
Modal,
|
|
ScrollView,
|
|
Text,
|
|
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 { Button } from '../Button';
|
|
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>
|
|
<Button
|
|
title={t('common.back')}
|
|
onPress={onClose}
|
|
variant="ghost"
|
|
/>
|
|
</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 }}>
|
|
<Button
|
|
title={isDowngrade ? t('plan.change.cta_confirm_downgrade') : t('plan.change.cta_confirm_upgrade')}
|
|
onPress={onConfirm}
|
|
size="lg"
|
|
/>
|
|
|
|
{isDowngrade && (
|
|
<Button
|
|
title={t('plan.change.cta_stay', { plan: PLAN_LABEL[preview.from] })}
|
|
onPress={onClose}
|
|
variant="ghost"
|
|
/>
|
|
)}
|
|
|
|
{!isDowngrade && (
|
|
<Button
|
|
title={t('common.cancel')}
|
|
onPress={onClose}
|
|
variant="ghost"
|
|
/>
|
|
)}
|
|
</View>
|
|
</>
|
|
);
|
|
}
|