chahinebrini 27ad05b13b fix(vip): Swap-Dialog-Polish — Inline-Button, Sortierung, Badge-Farben
- VipSwapSheet: Ersetzen-Button inline an der gewählten Domain-Zeile
  statt Modal-Footer-CTA
- VipDomainList: zu ersetzende Domain nach oben sortiert, Cooldown-Badge
  deutlicher (Icon + Border)
- Status-Badge einheitlich grün, Swap-Domain orange (kein Blau mehr)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:09:00 +02:00

266 lines
6.9 KiB
TypeScript

import { useState } from 'react';
import { ActivityIndicator, ScrollView, Text, TouchableOpacity, View } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { FormSheet } from '../FormSheet';
import { useColors } from '../../lib/theme';
import type { CustomDomain } from '../../hooks/useCustomDomains';
type Props = {
visible: boolean;
newDomainId: string;
candidates: CustomDomain[];
onClose: () => void;
onSwap: (newDomainId: string, evictedDomainId: string) => Promise<{ ok: boolean; error?: string }>;
};
function isVipEligible(d: CustomDomain): boolean {
if (d.type !== 'web') return false;
if (d.status === 'rejected') return false;
const now = Date.now();
if (d.vipDeferUntil && new Date(d.vipDeferUntil).getTime() > now) return false;
if (d.vipEvictAt && new Date(d.vipEvictAt).getTime() < now) return false;
return true;
}
export function VipSwapSheet({ visible, newDomainId, candidates, onClose, onSwap }: Props) {
const { t } = useTranslation();
const colors = useColors();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const eligible = candidates.filter(isVipEligible);
function close() {
setSelectedId(null);
setError(null);
onClose();
}
async function handleSwap(evictedId: string) {
if (submitting) return;
setSubmitting(true);
setError(null);
const result = await onSwap(newDomainId, evictedId);
setSubmitting(false);
if (result.ok) {
close();
return;
}
setError(t('blocker.vip_swap_error'));
}
return (
<FormSheet
visible={visible}
onClose={close}
title={t('blocker.vip_swap_title')}
>
<ScrollView
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 16, gap: 12 }}
>
<View
style={{
flexDirection: 'row',
gap: 10,
padding: 12,
backgroundColor: '#fff7ed',
borderRadius: 12,
borderWidth: 1,
borderColor: '#fed7aa',
}}
>
<Ionicons name="swap-horizontal" size={18} color="#c2410c" style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#7c2d12',
lineHeight: 18,
}}
>
{t('blocker.vip_swap_desc')}
</Text>
</View>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: colors.text,
marginTop: 4,
}}
>
{t('blocker.vip_swap_pick')}
</Text>
{eligible.length === 0 ? (
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
textAlign: 'center',
paddingVertical: 16,
}}
>
{t('blocker.vip_swap_no_candidates')}
</Text>
) : (
<View style={{ gap: 8 }}>
{eligible.map((d) => (
<SwapCandidateTile
key={d.id}
domain={d}
selected={selectedId === d.id}
submitting={submitting && selectedId === d.id}
onSelect={() => setSelectedId(selectedId === d.id ? null : d.id)}
onConfirm={() => handleSwap(d.id)}
colors={colors}
/>
))}
</View>
)}
{error && (
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
textAlign: 'center',
}}
>
{error}
</Text>
)}
</ScrollView>
</FormSheet>
);
}
function SwapCandidateTile({
domain,
selected,
submitting,
onSelect,
onConfirm,
colors,
}: {
domain: CustomDomain;
selected: boolean;
submitting: boolean;
onSelect: () => void;
onConfirm: () => void;
colors: ReturnType<typeof useColors>;
}) {
const { t } = useTranslation();
const [imgError, setImgError] = useState(false);
const stripped = domain.domain.replace(/^www\./, '');
return (
<View
style={{
borderRadius: 12,
borderWidth: 1.5,
borderColor: selected ? '#c2410c' : colors.border,
backgroundColor: selected ? '#fff7ed' : colors.surface,
overflow: 'hidden',
}}
>
<TouchableOpacity
onPress={onSelect}
activeOpacity={0.75}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 12,
padding: 12,
}}
>
{!imgError ? (
<Image
source={{ uri: `https://www.google.com/s2/favicons?domain=${stripped}&sz=64` }}
style={{ width: 28, height: 28, borderRadius: 6 }}
onError={() => setImgError(true)}
/>
) : (
<View
style={{
width: 28,
height: 28,
borderRadius: 6,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text
style={{
fontSize: 9,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
}}
>
{stripped.slice(0, 2).toUpperCase()}
</Text>
</View>
)}
<Text
style={{
flex: 1,
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: colors.text,
}}
numberOfLines={1}
>
{stripped}
</Text>
<Ionicons
name={selected ? 'chevron-up' : 'chevron-down'}
size={16}
color={selected ? '#c2410c' : colors.textMuted}
/>
</TouchableOpacity>
{selected && (
<TouchableOpacity
onPress={onConfirm}
disabled={submitting}
activeOpacity={0.85}
style={{
marginHorizontal: 12,
marginBottom: 12,
borderRadius: 10,
backgroundColor: '#c2410c',
paddingVertical: 12,
alignItems: 'center',
}}
>
{submitting ? (
<ActivityIndicator color="#fff" />
) : (
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_700Bold',
color: '#fff',
}}
>
{t('blocker.vip_swap_cta')}
</Text>
)}
</TouchableOpacity>
)}
</View>
);
}