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>
This commit is contained in:
chahinebrini 2026-05-22 21:09:00 +02:00
parent dc841b4275
commit 27ad05b13b
2 changed files with 122 additions and 134 deletions

View File

@ -475,7 +475,18 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps)
const list = vipList ?? [...customStatusMap.keys()]; const list = vipList ?? [...customStatusMap.keys()];
const customDomains = list.filter((d) => customStatusMap.has(d)); const now = Date.now();
const customDomains = list
.filter((d) => customStatusMap.has(d))
.sort((a, b) => {
const evictA = customStatusMap.get(a)?.vipEvictAt;
const evictB = customStatusMap.get(b)?.vipEvictAt;
const aIsPending = evictA ? new Date(evictA).getTime() > now : false;
const bIsPending = evictB ? new Date(evictB).getTime() > now : false;
if (aIsPending && !bIsPending) return -1;
if (!aIsPending && bIsPending) return 1;
return 0;
});
const curatedDomains = list.filter((d) => !customStatusMap.has(d)); const curatedDomains = list.filter((d) => !customStatusMap.has(d));
function getMeta(d: string): VipCustomMeta { function getMeta(d: string): VipCustomMeta {
@ -637,18 +648,17 @@ function VipCustomTile({
return Math.ceil(ms / (1000 * 60 * 60)); return Math.ceil(ms / (1000 * 60 * 60));
})(); })();
const isEvictPending = evictBadgeHours !== null;
const statusColor: string = (() => { const statusColor: string = (() => {
switch (status) { if (status === 'submitted') return colors.warning;
case 'submitted': return colors.warning; if (isEvictPending) return '#f97316';
case 'approved': return '#22c55e'; return '#22c55e';
default: return colors.brandOrange;
}
})(); })();
const badgeLabel: string = (() => { const badgeLabel: string = (() => {
switch (status) { switch (status) {
case 'submitted': return t('blocker.domain_badge_pruefung'); case 'submitted': return t('blocker.domain_badge_pruefung');
case 'approved': return t('blocker.domain_badge_active');
default: return t('blocker.domain_badge_active'); default: return t('blocker.domain_badge_active');
} }
})(); })();
@ -722,16 +732,21 @@ function VipCustomTile({
{evictBadgeHours !== null && ( {evictBadgeHours !== null && (
<View <View
style={{ style={{
backgroundColor: '#fef3c7', backgroundColor: '#fff7ed',
borderRadius: 6, borderRadius: 6,
paddingVertical: 2, borderWidth: 1,
borderColor: '#fed7aa',
paddingVertical: 3,
paddingHorizontal: 4, paddingHorizontal: 4,
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 2,
}} }}
> >
<Ionicons name="time-outline" size={8} color="#c2410c" />
<Text <Text
numberOfLines={1} numberOfLines={1}
style={{ fontSize: 7, fontFamily: 'Nunito_600SemiBold', color: '#92400e' }} style={{ fontSize: 8, fontFamily: 'Nunito_700Bold', color: '#c2410c' }}
> >
{t('blocker.vip_evict_badge', { hours: evictBadgeHours })} {t('blocker.vip_evict_badge', { hours: evictBadgeHours })}
</Text> </Text>

View File

@ -39,11 +39,11 @@ export function VipSwapSheet({ visible, newDomainId, candidates, onClose, onSwap
onClose(); onClose();
} }
async function handleSwap() { async function handleSwap(evictedId: string) {
if (!selectedId || submitting) return; if (submitting) return;
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
const result = await onSwap(newDomainId, selectedId); const result = await onSwap(newDomainId, evictedId);
setSubmitting(false); setSubmitting(false);
if (result.ok) { if (result.ok) {
close(); close();
@ -52,8 +52,6 @@ export function VipSwapSheet({ visible, newDomainId, candidates, onClose, onSwap
setError(t('blocker.vip_swap_error')); setError(t('blocker.vip_swap_error'));
} }
const ctaEnabled = selectedId !== null && !submitting;
return ( return (
<FormSheet <FormSheet
visible={visible} visible={visible}
@ -65,7 +63,6 @@ export function VipSwapSheet({ visible, newDomainId, candidates, onClose, onSwap
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 16, gap: 12 }} contentContainerStyle={{ padding: 16, gap: 12 }}
> >
{/* Erklärtext */}
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
@ -91,7 +88,6 @@ export function VipSwapSheet({ visible, newDomainId, candidates, onClose, onSwap
</Text> </Text>
</View> </View>
{/* Pick-Label */}
<Text <Text
style={{ style={{
fontSize: 13, fontSize: 13,
@ -103,7 +99,6 @@ export function VipSwapSheet({ visible, newDomainId, candidates, onClose, onSwap
{t('blocker.vip_swap_pick')} {t('blocker.vip_swap_pick')}
</Text> </Text>
{/* Domain-Liste */}
{eligible.length === 0 ? ( {eligible.length === 0 ? (
<Text <Text
style={{ style={{
@ -123,7 +118,9 @@ export function VipSwapSheet({ visible, newDomainId, candidates, onClose, onSwap
key={d.id} key={d.id}
domain={d} domain={d}
selected={selectedId === d.id} selected={selectedId === d.id}
onSelect={() => setSelectedId(d.id)} submitting={submitting && selectedId === d.id}
onSelect={() => setSelectedId(selectedId === d.id ? null : d.id)}
onConfirm={() => handleSwap(d.id)}
colors={colors} colors={colors}
/> />
))} ))}
@ -142,62 +139,6 @@ export function VipSwapSheet({ visible, newDomainId, candidates, onClose, onSwap
{error} {error}
</Text> </Text>
)} )}
{/* Buttons */}
<View style={{ flexDirection: 'row', gap: 10, marginTop: 4 }}>
<TouchableOpacity onPress={close} activeOpacity={0.8} style={{ flex: 1 }}>
<View
style={{
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
}}
>
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
}}
>
{t('common.cancel')}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={handleSwap}
disabled={!ctaEnabled}
activeOpacity={0.85}
style={{ flex: 2 }}
>
<View
style={{
backgroundColor: ctaEnabled ? '#c2410c' : '#d4d4d4',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
}}
>
{submitting ? (
<ActivityIndicator color="#fff" />
) : (
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#fff',
}}
>
{t('blocker.vip_swap_cta')}
</Text>
)}
</View>
</TouchableOpacity>
</View>
</ScrollView> </ScrollView>
</FormSheet> </FormSheet>
); );
@ -206,87 +147,119 @@ export function VipSwapSheet({ visible, newDomainId, candidates, onClose, onSwap
function SwapCandidateTile({ function SwapCandidateTile({
domain, domain,
selected, selected,
submitting,
onSelect, onSelect,
onConfirm,
colors, colors,
}: { }: {
domain: CustomDomain; domain: CustomDomain;
selected: boolean; selected: boolean;
submitting: boolean;
onSelect: () => void; onSelect: () => void;
onConfirm: () => void;
colors: ReturnType<typeof useColors>; colors: ReturnType<typeof useColors>;
}) { }) {
const { t } = useTranslation();
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
const stripped = domain.domain.replace(/^www\./, ''); const stripped = domain.domain.replace(/^www\./, '');
return ( return (
<TouchableOpacity <View
onPress={onSelect}
activeOpacity={0.75}
style={{ style={{
flexDirection: 'row',
alignItems: 'center',
gap: 12,
padding: 12,
borderRadius: 12, borderRadius: 12,
borderWidth: 1.5, borderWidth: 1.5,
borderColor: selected ? '#c2410c' : colors.border, borderColor: selected ? '#c2410c' : colors.border,
backgroundColor: selected ? '#fff7ed' : colors.surface, backgroundColor: selected ? '#fff7ed' : colors.surface,
overflow: 'hidden',
}} }}
> >
{!imgError ? ( <TouchableOpacity
<Image onPress={onSelect}
source={{ uri: `https://www.google.com/s2/favicons?domain=${stripped}&sz=64` }} activeOpacity={0.75}
style={{ width: 28, height: 28, borderRadius: 6 }} style={{
onError={() => setImgError(true)} flexDirection: 'row',
/> alignItems: 'center',
) : ( gap: 12,
<View padding: 12,
style={{ }}
width: 28, >
height: 28, {!imgError ? (
borderRadius: 6, <Image
backgroundColor: colors.surfaceElevated, source={{ uri: `https://www.google.com/s2/favicons?domain=${stripped}&sz=64` }}
alignItems: 'center', style={{ width: 28, height: 28, borderRadius: 6 }}
justifyContent: 'center', onError={() => setImgError(true)}
}} />
> ) : (
<Text <View
style={{ style={{
fontSize: 9, width: 28,
fontFamily: 'Nunito_700Bold', height: 28,
color: colors.textMuted, borderRadius: 6,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}} }}
> >
{stripped.slice(0, 2).toUpperCase()} <Text
</Text> style={{
</View> 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>
<Text
style={{
flex: 1,
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: colors.text,
}}
numberOfLines={1}
>
{stripped}
</Text>
<View
style={{
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 1.5,
borderColor: selected ? '#c2410c' : colors.border,
backgroundColor: selected ? '#c2410c' : 'transparent',
alignItems: 'center',
justifyContent: 'center',
}}
>
{selected && <Ionicons name="checkmark" size={13} color="#fff" />}
</View>
</TouchableOpacity>
); );
} }