feat(vip): VIP-Slot-Replace Frontend — Swap-Dialog + Cooldown-Badge

- useCustomDomains: CustomDomain um vipDeferUntil/vipEvictAt, AddDomainResult
  um vipFull/newDomainId; addDomain liefert vipFull durch; submitVipSwap()
- VipSwapSheet (neu): Dialog wenn VIP voll — User wählt eine eigene Domain,
  die in 24h ersetzt wird
- VipDomainList: Badge „wird in Xh ersetzt" auf der ersetzten Kachel
- blocker.tsx: vipFull → AddDomainSheet zu, VipSwapSheet auf

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-22 20:07:36 +02:00
parent 93eb3aceec
commit bee1d9900a
6 changed files with 407 additions and 12 deletions

View File

@ -8,6 +8,7 @@ import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard';
import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard';
import { CooldownBanner } from '../../components/blocker/CooldownBanner';
import { AddDomainSheet } from '../../components/blocker/AddDomainSheet';
import { VipSwapSheet } from '../../components/blocker/VipSwapSheet';
import { MyFiltersList, VipDomainList } from '../../components/blocker/VipDomainList';
import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet';
import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet';
@ -47,6 +48,7 @@ export default function BlockerScreen() {
limit: domainLimit,
addDomain,
submitDomain,
submitVipSwap,
refresh: refreshDomains,
} = useCustomDomains(plan);
const { sync: syncBlocklist, syncWebContent } = useBlocklistSync();
@ -64,6 +66,8 @@ export default function BlockerScreen() {
const [vipOpen, setVipOpen] = useState(false);
const [addSheetOpen, setAddSheetOpen] = useState(false);
const [vipSwapOpen, setVipSwapOpen] = useState(false);
const [pendingNewDomainId, setPendingNewDomainId] = useState<string>('');
const [detailsOpen, setDetailsOpen] = useState(false);
const [explainerOpen, setExplainerOpen] = useState(false);
@ -355,6 +359,28 @@ export default function BlockerScreen() {
syncWebContent();
const sync = await syncBlocklist();
if (sync.ok) refresh();
if (result.vipFull && result.newDomainId) {
setAddSheetOpen(false);
setPendingNewDomainId(result.newDomainId);
setVipSwapOpen(true);
}
}
return result;
}}
/>
<VipSwapSheet
visible={vipSwapOpen}
newDomainId={pendingNewDomainId}
candidates={domains}
onClose={() => {
setVipSwapOpen(false);
setPendingNewDomainId('');
}}
onSwap={async (newId, evictedId) => {
const result = await submitVipSwap(newId, evictedId);
if (result.ok) {
syncWebContent();
}
return result;
}}

View File

@ -9,6 +9,8 @@ import { SuccessAlert } from '../SuccessAlert';
import { useWebContentDomains } from '../../hooks/useWebContentDomains';
import type { CustomDomain, DomainStatus, Tier } from '../../hooks/useCustomDomains';
type VipCustomMeta = { status: DomainStatus; vipEvictAt: string | null | undefined };
// ─── Meine Filter (unified web + mail_domain) ─────────────────────────────────
type MyFiltersProps = {
@ -451,8 +453,10 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps)
[domains],
);
const customStatusMap = useMemo(() => {
const m = new Map<string, DomainStatus>();
for (const d of webCustoms) m.set(d.domain.replace(/^www\./, ''), d.status);
const m = new Map<string, VipCustomMeta>();
for (const d of webCustoms) {
m.set(d.domain.replace(/^www\./, ''), { status: d.status, vipEvictAt: d.vipEvictAt });
}
return m;
}, [webCustoms]);
@ -474,6 +478,10 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps)
const customDomains = list.filter((d) => customStatusMap.has(d));
const curatedDomains = list.filter((d) => !customStatusMap.has(d));
function getMeta(d: string): VipCustomMeta {
return customStatusMap.get(d) ?? { status: 'active', vipEvictAt: null };
}
return (
<View
style={{
@ -531,14 +539,18 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps)
count={t('blocker.vip_section_custom_count', { count: customDomains.length })}
colors={colors}
>
{customDomains.map((d) => (
<VipCustomTile
key={d}
domain={d}
status={customStatusMap.get(d)!}
colors={colors}
/>
))}
{customDomains.map((d) => {
const meta = getMeta(d);
return (
<VipCustomTile
key={d}
domain={d}
status={meta.status}
vipEvictAt={meta.vipEvictAt}
colors={colors}
/>
);
})}
</VipSubSection>
)}
@ -606,16 +618,25 @@ function VipSubSection({
function VipCustomTile({
domain,
status,
vipEvictAt,
colors,
}: {
domain: string;
status: DomainStatus;
vipEvictAt?: string | null;
colors: ColorScheme;
}) {
const { t } = useTranslation();
const [imgError, setImgError] = useState(false);
const stripped = domain.replace(/^www\./, '');
const evictBadgeHours: number | null = (() => {
if (!vipEvictAt) return null;
const ms = new Date(vipEvictAt).getTime() - Date.now();
if (ms <= 0) return null;
return Math.ceil(ms / (1000 * 60 * 60));
})();
const statusColor: string = (() => {
switch (status) {
case 'submitted': return colors.warning;
@ -697,6 +718,25 @@ function VipCustomTile({
{stripped}
</Text>
</View>
{evictBadgeHours !== null && (
<View
style={{
backgroundColor: '#fef3c7',
borderRadius: 6,
paddingVertical: 2,
paddingHorizontal: 4,
alignItems: 'center',
}}
>
<Text
numberOfLines={1}
style={{ fontSize: 7, fontFamily: 'Nunito_600SemiBold', color: '#92400e' }}
>
{t('blocker.vip_evict_badge', { hours: evictBadgeHours })}
</Text>
</View>
)}
</View>
);
}

View File

@ -0,0 +1,292 @@
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() {
if (!selectedId || submitting) return;
setSubmitting(true);
setError(null);
const result = await onSwap(newDomainId, selectedId);
setSubmitting(false);
if (result.ok) {
close();
return;
}
setError(t('blocker.vip_swap_error'));
}
const ctaEnabled = selectedId !== null && !submitting;
return (
<FormSheet
visible={visible}
onClose={close}
title={t('blocker.vip_swap_title')}
>
<ScrollView
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 16, gap: 12 }}
>
{/* Erklärtext */}
<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>
{/* Pick-Label */}
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: colors.text,
marginTop: 4,
}}
>
{t('blocker.vip_swap_pick')}
</Text>
{/* Domain-Liste */}
{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}
onSelect={() => setSelectedId(d.id)}
colors={colors}
/>
))}
</View>
)}
{error && (
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
textAlign: 'center',
}}
>
{error}
</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>
</FormSheet>
);
}
function SwapCandidateTile({
domain,
selected,
onSelect,
colors,
}: {
domain: CustomDomain;
selected: boolean;
onSelect: () => void;
colors: ReturnType<typeof useColors>;
}) {
const [imgError, setImgError] = useState(false);
const stripped = domain.domain.replace(/^www\./, '');
return (
<TouchableOpacity
onPress={onSelect}
activeOpacity={0.75}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 12,
padding: 12,
borderRadius: 12,
borderWidth: 1.5,
borderColor: selected ? '#c2410c' : colors.border,
backgroundColor: selected ? '#fff7ed' : colors.surface,
}}
>
{!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>
<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>
);
}

View File

@ -14,6 +14,8 @@ export type CustomDomain = {
addedAt?: string;
postId?: string | null;
submission?: { id: string; yesVotes: number; noVotes: number; status: string } | null;
vipDeferUntil?: string | null;
vipEvictAt?: string | null;
};
export type Plan = 'free' | 'pro' | 'legend';
@ -34,6 +36,8 @@ export type AddDomainResult = {
alreadyProtected?: boolean;
inGlobalNotVip?: boolean;
addedToVip?: boolean;
vipFull?: boolean;
newDomainId?: string;
};
export type Tier = {
@ -79,6 +83,7 @@ export type UseCustomDomainsReturn = {
) => Promise<AddDomainResult>;
submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
removeDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
submitVipSwap: (newDomainId: string, evictedDomainId: string) => Promise<{ ok: boolean; error?: string }>;
/** Live-Validate (regex) ob string gültiger Domain-Name ist. */
isValidDomain: (s: string) => boolean;
/** Normalize: lowercase, http(s)://, /path stripping, www. weg. */
@ -186,6 +191,7 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
if (res?.alreadyProtected) return { ok: false, alreadyProtected: true };
if (res?.inGlobalNotVip) return { ok: false, inGlobalNotVip: true };
await fetchDomains();
if (res?.vipFull) return { ok: true, vipFull: true, newDomainId: res.id };
return { ok: true, addedToVip: res?.addedToVip === true };
} catch (e: any) {
return { ok: false, error: e?.message ?? 'add_failed' };
@ -222,6 +228,22 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
[fetchDomains],
);
const submitVipSwap = useCallback(
async (newDomainId: string, evictedDomainId: string) => {
try {
await apiFetch('/api/custom-domains/vip-swap', {
method: 'POST',
body: { newDomainId, evictedDomainId },
});
await fetchDomains();
return { ok: true };
} catch (e: any) {
return { ok: false, error: e?.message ?? 'vip_swap_failed' };
}
},
[fetchDomains],
);
const tier = deriveTier(plan, domains);
// API-Werte bevorzugen (Single Source of Truth); lokale Ableitung als
@ -242,6 +264,7 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
addDomain,
submitDomain,
removeDomain,
submitVipSwap,
isValidDomain,
normalizeDomain,
};

View File

@ -409,7 +409,14 @@
"remove_domain_failed": "Entfernen fehlgeschlagen.",
"remove_domain_actionsheet_title": "Domain wirklich entfernen?",
"remove_domain_actionsheet_message": "%{domain} wird sofort aus deiner Sperrliste gelöscht.",
"remove_domain_confirm_cta": "Entfernen"
"remove_domain_confirm_cta": "Entfernen",
"vip_swap_title": "VIP-Liste ist voll",
"vip_swap_desc": "Deine VIP-Liste ist voll. Waehle eine Domain, die in 24 Stunden ersetzt wird — sie bleibt bis dahin geschuetzt, danach uebernimmt die neue.",
"vip_swap_pick": "Welche Domain soll ersetzt werden?",
"vip_swap_cta": "Austauschen",
"vip_swap_no_candidates": "Keine tauschbaren Domains gefunden.",
"vip_swap_error": "Austausch fehlgeschlagen. Bitte erneut versuchen.",
"vip_evict_badge": "wird in %{hours}h ersetzt"
},
"onboarding": {
"lyra": {

View File

@ -409,7 +409,14 @@
"remove_domain_failed": "Remove failed.",
"remove_domain_actionsheet_title": "Really remove domain?",
"remove_domain_actionsheet_message": "%{domain} will be immediately deleted from your blocklist.",
"remove_domain_confirm_cta": "Remove"
"remove_domain_confirm_cta": "Remove",
"vip_swap_title": "VIP list is full",
"vip_swap_desc": "Your VIP list is full. Choose one of your domains to be replaced in 24 hours — it stays protected until then, after which the new domain takes over.",
"vip_swap_pick": "Which domain should be replaced?",
"vip_swap_cta": "Swap",
"vip_swap_no_candidates": "No swappable domains found.",
"vip_swap_error": "Swap failed. Please try again.",
"vip_evict_badge": "replaced in %{hours}h"
},
"onboarding": {
"lyra": {