diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx
index d9c3594..6500c31 100644
--- a/apps/rebreak-native/app/(app)/blocker.tsx
+++ b/apps/rebreak-native/app/(app)/blocker.tsx
@@ -10,6 +10,7 @@ import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedC
import { CooldownBanner } from '../../components/blocker/CooldownBanner';
import { DomainGrid } from '../../components/blocker/DomainGrid';
import { AddDomainSheet } from '../../components/blocker/AddDomainSheet';
+import { VipDomainList } from '../../components/blocker/VipDomainList';
import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet';
import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet';
import { PermissionDeniedSheet } from '../../components/PermissionDeniedSheet';
@@ -48,6 +49,7 @@ export default function BlockerScreen() {
limits,
addDomain,
submitDomain,
+ removeDomain,
refresh: refreshDomains,
} = useCustomDomains(plan);
const { sync: syncBlocklist } = useBlocklistSync();
@@ -219,6 +221,14 @@ export default function BlockerScreen() {
}
}
+ async function handleRemoveWebDomain(id: string) {
+ const result = await removeDomain(id);
+ if (result.ok) {
+ const sync = await syncBlocklist();
+ if (sync.ok) refresh();
+ }
+ }
+
const bypassAlertShownRef = useRef(false);
useEffect(() => {
if (state?.phase !== 'recoveringFromBypass') {
@@ -309,26 +319,16 @@ export default function BlockerScreen() {
/>
)}
- {/* Free: Erwartungs-Transparenz-Hinweis */}
- {plan === 'free' && (
-
-
-
- {t('plan_limit.blocker_basic_protection')}
-
-
- )}
+ {/* VIP-Liste: Meine geblockten Seiten */}
+ setAddSheetOpen(true)}
+ onRemoveDomain={handleRemoveWebDomain}
+ colors={colors}
+ />
{/* Custom-Filter-Slot-Übersicht */}
void;
+ onConfirmRemove: () => Promise;
+};
+
+/**
+ * 3-Click Friction-Gate vor dem Entfernen einer eigenen Web-Domain.
+ *
+ * Selbes UX-Muster wie DeactivationExplainerSheet:
+ * Click 1: User tippt Papierkorb-Icon → Sheet öffnet sich (dieser Component)
+ * Click 2: User liest Kontext → primäre Aktion = "Behalten" (Deflect)
+ * sekundäre Aktion = "Trotzdem entfernen" (klein, destructive)
+ * Click 3: ActionSheet / Alert zur finalen Bestätigung → removeDomain()
+ *
+ * Kein Cooldown wird gestartet — nur der Domain-Delete-Endpoint.
+ * Die Verzögerung entsteht durch das bewusste 3-Schritt-UX, nicht durch
+ * eine zeitliche Sperre (anders als beim Schutz-Deaktivieren).
+ */
+export function RemoveDomainSheet({ visible, domain, onClose, onConfirmRemove }: Props) {
+ const { t } = useTranslation();
+ const colors = useColors();
+ const insets = useSafeAreaInsets();
+ const [submitting, setSubmitting] = useState(false);
+
+ function showFinalConfirm() {
+ const title = t('blocker.remove_domain_actionsheet_title');
+ const message = t('blocker.remove_domain_actionsheet_message', { domain });
+ const cancelLabel = t('common.cancel');
+ const confirmLabel = t('blocker.remove_domain_confirm_cta');
+
+ if (Platform.OS === 'ios') {
+ ActionSheetIOS.showActionSheetWithOptions(
+ {
+ title,
+ message,
+ options: [cancelLabel, confirmLabel],
+ destructiveButtonIndex: 1,
+ cancelButtonIndex: 0,
+ },
+ async (idx) => {
+ if (idx === 1) await runRemove();
+ },
+ );
+ } else {
+ Alert.alert(title, message, [
+ { text: cancelLabel, style: 'cancel' },
+ { text: confirmLabel, style: 'destructive', onPress: runRemove },
+ ]);
+ }
+ }
+
+ async function runRemove() {
+ setSubmitting(true);
+ try {
+ await onConfirmRemove();
+ onClose();
+ } catch (e: any) {
+ Alert.alert(t('common.error'), e?.message ?? t('blocker.remove_domain_failed'));
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+ {t('blocker.remove_domain_title')}
+
+
+
+
+
+ {domain}
+
+
+
+
+ {t('blocker.remove_domain_intro')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('blocker.remove_domain_keep_cta')}
+
+
+
+
+
+
+ {submitting ? t('blocker.remove_domain_removing') : t('blocker.remove_domain_remove_anyway')}
+
+
+
+
+ );
+}
+
+function BulletRow({
+ icon,
+ title,
+ text,
+}: {
+ icon: React.ComponentProps['name'];
+ title: string;
+ text: string;
+}) {
+ const colors = useColors();
+ return (
+
+
+
+
+
+
+ {title}
+
+
+ {text}
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/blocker/VipDomainList.tsx b/apps/rebreak-native/components/blocker/VipDomainList.tsx
new file mode 100644
index 0000000..49fef73
--- /dev/null
+++ b/apps/rebreak-native/components/blocker/VipDomainList.tsx
@@ -0,0 +1,356 @@
+import { useRef, useEffect, useState, useMemo } from 'react';
+import { View, Text, TouchableOpacity, Animated, ActivityIndicator } from 'react-native';
+import { Image } from 'expo-image';
+import { Ionicons } from '@expo/vector-icons';
+import { useTranslation } from 'react-i18next';
+import { useColors, type ColorScheme } from '../../lib/theme';
+import { RemoveDomainSheet } from './RemoveDomainSheet';
+import type { CustomDomain } from '../../hooks/useCustomDomains';
+
+type Props = {
+ domains: CustomDomain[];
+ webCount: number;
+ webLimit: number;
+ globalBlocklistCount: number;
+ onAddPress: () => void;
+ onRemoveDomain: (id: string) => Promise;
+ colors: ColorScheme;
+};
+
+/**
+ * VIP-Sektion: "Meine geblockten Seiten".
+ *
+ * Zeigt eigene Web-Custom-Domains des Users (kind='web') + Zähler (X / Limit).
+ * Domain entfernen → 3-Click Friction via RemoveDomainSheet (selbes Pattern wie
+ * Schutz-Deaktivieren). Domain hinzufügen → frei via onAddPress.
+ * Darunter: ruhiger Hinweis auf die automatische globale Blocklist.
+ */
+export function VipDomainList({
+ domains,
+ webCount,
+ webLimit,
+ globalBlocklistCount,
+ onAddPress,
+ onRemoveDomain,
+ colors,
+}: Props) {
+ const { t } = useTranslation();
+
+ const webDomains = useMemo(
+ () => domains.filter((d) => (d.type === 'web' || !d.type) && d.status !== 'approved'),
+ [domains],
+ );
+
+ const atLimit = webCount >= webLimit;
+ const fillAnim = useRef(new Animated.Value(0)).current;
+ const ratio = webLimit > 0 ? Math.min(webCount / webLimit, 1) : 0;
+
+ useEffect(() => {
+ Animated.timing(fillAnim, { toValue: ratio, duration: 380, useNativeDriver: false }).start();
+ }, [ratio]);
+
+ const pct = ratio * 100;
+ const barColor = pct >= 90 ? colors.error : pct >= 60 ? colors.warning : colors.brandOrange;
+
+ const badgeBg = atLimit ? '#fee2e2' : colors.surfaceElevated;
+ const badgeFg = atLimit ? colors.error : colors.textMuted;
+
+ return (
+
+ {/* Header */}
+
+
+ {t('blocker.vip_section_title')}
+
+
+
+ {t('blocker.count_label', { count: webCount, max: webLimit })}
+
+
+
+
+
+
+
+
+ {/* Progress bar */}
+
+
+
+
+ {/* Domain list or empty state */}
+ {webDomains.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ {/* Global list hint — ruhig, nicht als Casino-Trigger */}
+
+
+
+ {t('blocker.vip_global_hint', { count: globalBlocklistCount })}
+
+
+
+
+ );
+}
+
+// ─── Empty State ──────────────────────────────────────────────────────────────
+
+function VipEmptyState({ onAddPress, colors }: { onAddPress: () => void; colors: ColorScheme }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+ {t('blocker.vip_empty')}
+
+
+
+ );
+}
+
+// ─── Domain Tiles ─────────────────────────────────────────────────────────────
+
+function VipDomainTiles({
+ domains,
+ onRemoveDomain,
+}: {
+ domains: CustomDomain[];
+ onRemoveDomain: (id: string) => Promise;
+}) {
+ return (
+
+ {domains.map((d) => (
+
+ ))}
+
+ );
+}
+
+function VipDomainTile({
+ domain,
+ onRemove,
+}: {
+ domain: CustomDomain;
+ onRemove: (id: string) => Promise;
+}) {
+ const { t } = useTranslation();
+ const colors = useColors();
+ const [imgError, setImgError] = useState(false);
+ const [removeSheetOpen, setRemoveSheetOpen] = useState(false);
+ const [removing, setRemoving] = useState(false);
+
+ const stripped = domain.domain.replace(/^www\./, '');
+
+ const statusColor: string = (() => {
+ switch (domain.status) {
+ case 'submitted': return colors.warning;
+ case 'rejected': return colors.error;
+ default: return colors.brandOrange;
+ }
+ })();
+
+ const badgeLabel: string = (() => {
+ switch (domain.status) {
+ case 'submitted': return t('blocker.domain_badge_pruefung');
+ case 'rejected': return t('blocker.domain_badge_rejected');
+ default: return t('blocker.domain_badge_active');
+ }
+ })();
+
+ async function handleConfirmRemove() {
+ setRemoving(true);
+ try {
+ await onRemove(domain.id);
+ } finally {
+ setRemoving(false);
+ }
+ }
+
+ return (
+ <>
+
+ {/* Status badge row */}
+
+
+
+ {badgeLabel}
+
+
+
+
+ {/* Favicon + domain name */}
+
+ {!imgError ? (
+ setImgError(true)}
+ />
+ ) : (
+
+
+ {stripped.slice(0, 2).toUpperCase()}
+
+
+ )}
+
+ {stripped}
+
+
+
+ {/* Remove button */}
+ setRemoveSheetOpen(true)}
+ disabled={removing}
+ activeOpacity={0.65}
+ style={{
+ height: 26,
+ borderRadius: 6,
+ borderWidth: 1,
+ borderColor: colors.border,
+ alignItems: 'center',
+ justifyContent: 'center',
+ }}
+ >
+ {removing ? (
+
+ ) : (
+
+ )}
+
+
+
+ setRemoveSheetOpen(false)}
+ onConfirmRemove={handleConfirmRemove}
+ />
+ >
+ );
+}
diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json
index 90983b8..ae77b4a 100644
--- a/apps/rebreak-native/locales/de.json
+++ b/apps/rebreak-native/locales/de.json
@@ -378,7 +378,24 @@
"error_duplicate": "Diesen Eintrag hast du schon — er ist bereits in deiner Filter-Liste.",
"kind_override_label": "Das ist eine E-Mail-Adresse / Mail-Absender",
"empty_web": "Noch keine eigenen Domains.\nTippe + um eine hinzuzufügen.",
- "empty_mail": "Noch keine Mail-Domains. Tippe + um eine E-Mail-Adresse oder Domain zu blockieren."
+ "empty_mail": "Noch keine Mail-Domains. Tippe + um eine E-Mail-Adresse oder Domain zu blockieren.",
+ "vip_section_title": "Meine geblockten Seiten",
+ "vip_empty": "Noch keine eigenen Seiten.\nTippe + um eine Website zu sperren.",
+ "vip_global_hint": "+ %{count} bekannte Glücksspielseiten automatisch geschützt",
+ "remove_domain_sheet_heading": "Domain entfernen",
+ "remove_domain_title": "Kurz nachdenken.",
+ "remove_domain_intro": "Du bist dabei, diese Seite aus deiner persönlichen Sperrliste zu entfernen. Das passiert sofort — sie wäre dann wieder erreichbar.",
+ "remove_domain_bullet1_title": "Schutz bleibt für andere Seiten",
+ "remove_domain_bullet1_text": "Die globale Blocklist mit 200.000+ Domains bleibt aktiv. Nur diese eine Seite wäre wieder frei.",
+ "remove_domain_bullet2_title": "Im Drang-Moment ist das riskant",
+ "remove_domain_bullet2_text": "Du hast diese Seite damals selbst gesperrt — wahrscheinlich aus gutem Grund. Warte bis der Drang nachlässt.",
+ "remove_domain_keep_cta": "Schutz behalten",
+ "remove_domain_remove_anyway": "Trotzdem entfernen",
+ "remove_domain_removing": "Wird entfernt…",
+ "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"
},
"onboarding": {
"lyra": {
diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json
index 1d88007..4889330 100644
--- a/apps/rebreak-native/locales/en.json
+++ b/apps/rebreak-native/locales/en.json
@@ -378,7 +378,24 @@
"error_duplicate": "You've already added this entry — it's in your filter list.",
"kind_override_label": "This is an email address / mail sender",
"empty_web": "No custom domains yet.\nTap + to add one.",
- "empty_mail": "No mail domains yet. Tap + to block an email address or domain."
+ "empty_mail": "No mail domains yet. Tap + to block an email address or domain.",
+ "vip_section_title": "My blocked sites",
+ "vip_empty": "No custom sites yet.\nTap + to block a website.",
+ "vip_global_hint": "+ %{count} known gambling sites automatically protected",
+ "remove_domain_sheet_heading": "Remove domain",
+ "remove_domain_title": "Take a moment.",
+ "remove_domain_intro": "You're about to remove this site from your personal blocklist. This takes effect immediately — the site would be reachable again.",
+ "remove_domain_bullet1_title": "Other sites stay protected",
+ "remove_domain_bullet1_text": "The global blocklist with 200,000+ domains stays active. Only this one site would be unblocked.",
+ "remove_domain_bullet2_title": "This is risky in a craving moment",
+ "remove_domain_bullet2_text": "You added this site yourself — probably for a good reason. Wait until the urge passes.",
+ "remove_domain_keep_cta": "Keep protection",
+ "remove_domain_remove_anyway": "Remove anyway",
+ "remove_domain_removing": "Removing…",
+ "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"
},
"onboarding": {
"lyra": {