import { useEffect, useRef, 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 { ConfirmAlert } from '../ConfirmAlert'; import { SuccessAlert } from '../SuccessAlert'; import { useWebContentDomains } from '../../hooks/useWebContentDomains'; import type { CustomDomain, DomainStatus, Tier } from '../../hooks/useCustomDomains'; // ─── Meine Filter (unified web + mail_domain) ───────────────────────────────── type MyFiltersProps = { domains: CustomDomain[]; tier: Tier; totalCount: number; totalLimit: number; globalBlocklistCount: number; onAddPress: () => void; onSubmitDomain: (id: string) => Promise<{ ok: boolean }>; colors: ColorScheme; }; /** * "Meine Filter" — unified Sektion für web + mail_domain Einträge. * * Ein Slot-Pool: totalLimit = Legend 20 / Pro 10 (web+mail zusammen). * Kacheln zeigen Typ-Badge (Web / Mail). Jede Kachel hat einen Freigabe-Button: * der User reicht die Domain an die globale Blocklist ein (Pro = Community-Vote, * Legend = Admin-Review). Bewusst KEIN Entfernen-Button — einmal gesperrt * bleibt gesperrt (Anti-Rückfall-Logik). */ export function MyFiltersList({ domains, tier, totalCount, totalLimit, globalBlocklistCount, onAddPress, onSubmitDomain, colors, }: MyFiltersProps) { const { t } = useTranslation(); const visibleDomains = useMemo( () => domains.filter((d) => d.status !== 'approved'), [domains], ); const atLimit = totalCount >= totalLimit; const fillAnim = useRef(new Animated.Value(0)).current; const ratio = totalLimit > 0 ? Math.min(totalCount / totalLimit, 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 ( {t('blocker.my_filters_title')} {t('blocker.count_label', { count: totalCount, max: totalLimit })} {visibleDomains.length === 0 ? ( ) : ( )} {t('blocker.vip_global_hint', { count: globalBlocklistCount })} ); } function MyFiltersEmptyState({ onAddPress, colors }: { onAddPress: () => void; colors: ColorScheme }) { const { t } = useTranslation(); return ( {t('blocker.my_filters_empty')} ); } function FilterTilesGrid({ domains, tier, onSubmit, }: { domains: CustomDomain[]; tier: Tier; onSubmit: (id: string) => Promise<{ ok: boolean }>; }) { return ( {domains.map((d) => ( ))} ); } function FilterTile({ domain, tier, onSubmit, }: { domain: CustomDomain; tier: Tier; onSubmit: (id: string) => Promise<{ ok: boolean }>; }) { const { t } = useTranslation(); const colors = useColors(); const [imgError, setImgError] = useState(false); const [submitting, setSubmitting] = useState(false); const [confirmVisible, setConfirmVisible] = useState(false); const [successVisible, setSuccessVisible] = useState(false); const isMail = domain.type === 'mail_domain'; const stripped = domain.domain.replace(/^www\./, ''); const isLegend = tier.plan === 'legend'; const isResubmit = domain.status === 'rejected'; // Freigabe-Button: nur für noch nicht eingereichte Einträge. mail_display_name // ist eine Substring-Heuristik — kann nicht in die globale Blocklist. const canSubmit = tier.canSubmit && (domain.status === 'active' || domain.status === 'rejected') && domain.type !== 'mail_display_name'; 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'); } })(); const btnColor = isResubmit ? colors.error : colors.brandOrange; const confirmTitle = isLegend ? isResubmit ? t('blocker.domain_confirm_legend_resubmit') : t('blocker.domain_confirm_legend_first') : isResubmit ? t('blocker.domain_confirm_community_resubmit') : t('blocker.domain_confirm_community_first'); const confirmMessage = isLegend ? t('blocker.domain_confirm_legend_message', { domain: stripped }) : t('blocker.domain_confirm_community_message', { domain: stripped }); async function handleConfirm() { setConfirmVisible(false); setSubmitting(true); try { const result = await onSubmit(domain.id); if (result.ok) setSuccessVisible(true); } finally { setSubmitting(false); } } return ( <> {/* Type + Status badge row */} {isMail ? t('blocker.type_mail') : t('blocker.type_web')} {badgeLabel} {/* Icon + label */} {isMail ? ( ) : !imgError ? ( setImgError(true)} /> ) : ( {stripped.slice(0, 2).toUpperCase()} )} {stripped} {/* Bottom slot — Freigabe / Erneut / in Prüfung. Immer 26px hoch, damit alle Kacheln gleich hoch bleiben. */} {domain.status === 'submitted' ? ( {isLegend ? t('blocker.domain_btn_rebreak_prueft') : t('blocker.domain_btn_in_abstimmung')} ) : canSubmit ? ( setConfirmVisible(true)} disabled={submitting} activeOpacity={0.65} style={{ flex: 1, borderRadius: 6, borderWidth: 1, borderColor: btnColor, alignItems: 'center', justifyContent: 'center', }} > {submitting ? ( ) : ( {isResubmit ? t('blocker.domain_btn_erneut') : t('blocker.domain_btn_freigeben')} )} ) : null} setConfirmVisible(false)} /> setSuccessVisible(false)} /> ); } // ─── VIP-Liste (collapsible, Zweitschutz) ──────────────────────────────────── type VipListProps = { domains: CustomDomain[]; open: boolean; onToggle: () => void; colors: ColorScheme; }; /** * "VIP-Liste" — Zweitschutz-Sektion. Collapsible. * * Zeigt die LANDABHÄNGIGE VIP-Layer-2-Liste, wie das Backend sie komponiert: * die eigenen Web-Domains des Users + die kuratierte globale Gambling-Liste * für die Geräte-Region (DE / GB / FR), dedupliziert, hart auf 50 gekappt. * * Diese Liste greift als Zweitschutz, falls Layer 1 (VPN/URL-Filter) ein * technisches Problem hat. */ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps) { const { t } = useTranslation(); const { domains: vipList, loading, refetch } = useWebContentDomains(); // Eigene Web-Domains (inkl. approved — die sind auch in der VIP). Map // domain → status, damit Chips ihre Herkunft + Bearbeitungszustand kennen. const webCustoms = useMemo( () => domains.filter( (d) => (d.type === 'web' || !d.type) && d.status !== 'rejected', ), [domains], ); const customStatusMap = useMemo(() => { const m = new Map(); for (const d of webCustoms) m.set(d.domain.replace(/^www\./, ''), d.status); return m; }, [webCustoms]); // Realtime: ändert sich die Custom-Domain-Liste (Add / Approve / Reject — // via useDomainSubmissionRealtime → refreshDomains), die komponierte VIP- // Liste neu vom Backend holen. Mount-Fetch macht der Hook schon selbst. const domainsSig = useMemo( () => domains.map((d) => `${d.id}:${d.status}`).join('|'), [domains], ); const firstRunRef = useRef(true); useEffect(() => { if (firstRunRef.current) { firstRunRef.current = false; return; } refetch(); }, [domainsSig, refetch]); // Endpoint-Liste bevorzugen; bis sie da ist, die eigenen Domains zeigen. const list = vipList ?? [...customStatusMap.keys()]; return ( {t('blocker.vip_layer2_title')} {open && ( {t('blocker.vip_layer2_desc')} {loading && vipList === null ? ( ) : ( <> {t('blocker.vip_layer2_count', { count: list.length })} {list.length > 0 && ( {list.map((d) => ( ))} )} )} )} ); } /** * VIP-Chip. Eigene Custom-Domains kriegen einen Stern; noch nicht final * abgeschlossene (active / submitted) zusätzlich einen pulsierenden Ring — * der signalisiert „neu / in Bearbeitung". Nach Approval (oder Reject → * verschwindet) wird via Realtime-Refetch ohne Ring neu gerendert. */ function VipReadonlyChip({ domain, status, colors, }: { domain: string; status?: DomainStatus; colors: ColorScheme; }) { const stripped = domain.replace(/^www\./, ''); const isCustom = status !== undefined; const isPending = status === 'active' || status === 'submitted'; const pulse = useRef(new Animated.Value(0)).current; useEffect(() => { if (!isPending) return; const loop = Animated.loop( Animated.sequence([ Animated.timing(pulse, { toValue: 1, duration: 850, useNativeDriver: true }), Animated.timing(pulse, { toValue: 0, duration: 850, useNativeDriver: true }), ]), ); loop.start(); return () => loop.stop(); }, [isPending, pulse]); return ( {isPending && ( )} {stripped} ); }