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": {