feat(blocker/vip): Freigabe-Button, landabhängige VIP-Liste, Hybrid-Komposition + Add-Check

Blocker-UI:
- FilterTile: Trash-Button → status-aware Freigabe-Button (Freigeben/Erneut/
  in-Prüfung); RemoveDomainSheet entfernt — kein Domain-Entfernen mehr in der UI
- VIP-Liste landabhängig: zeigt die komponierte Endpoint-Liste statt nur
  eigener Customs; Land über Geräte-Region (expo-localization)
- VIP-Realtime: refetch bei Domain-Add/Approve/Reject, pulsierender Ring
  für neue/active/submitted Chips

VIP-Komposition (webcontent-domains):
- Hybrid: Customs auf 30 gekappt, 20 Slots fest für die kuratierte Top-Liste
  reserviert — Customs können die Top-Gambling-Domains nicht verdrängen

Add-Check (custom-domains POST), für web reaktiviert — 3 Fälle gegen
Layer 1 (global) + Layer 2 (kuratierte VIP):
- weder global noch kuratiert → normaler active-Eintrag
- global + kuratiert → alreadyProtected, kein Slot
- global, nicht kuratiert → inGlobalNotVip; per addToVip als status=approved
  speicherbar (kein Slot, nur VIP-Liste)

DE-Gambling-Liste 30→36, nach Relevanz sortiert (erste 20 = reservierte Plätze)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-22 17:27:10 +02:00
parent e946fbe443
commit fe156a5f58
13 changed files with 749 additions and 576 deletions

View File

@ -46,7 +46,7 @@ export default function BlockerScreen() {
countsByType,
limits,
addDomain,
removeDomain,
submitDomain,
refresh: refreshDomains,
} = useCustomDomains(plan);
const { sync: syncBlocklist, syncWebContent } = useBlocklistSync();
@ -230,15 +230,6 @@ export default function BlockerScreen() {
}
}
async function handleRemoveWebDomain(id: string) {
const result = await removeDomain(id);
if (result.ok) {
syncWebContent();
const sync = await syncBlocklist();
if (sync.ok) refresh();
}
}
const bypassAlertShownRef = useRef(false);
useEffect(() => {
if (state?.phase !== 'recoveringFromBypass') {
@ -332,18 +323,18 @@ export default function BlockerScreen() {
{/* Sektion 1: Meine Filter (unified web + mail_domain) */}
<MyFiltersList
domains={domains}
tier={tier}
totalCount={countsByType.web + countsByType.mail}
totalLimit={limits.web + limits.mail}
globalBlocklistCount={state.blocklistCount}
onAddPress={() => setAddSheetOpen(true)}
onRemoveDomain={handleRemoveWebDomain}
onSubmitDomain={submitDomain}
colors={colors}
/>
{/* Sektion 2: VIP-Liste (Zweitschutz, collapsible) */}
<VipDomainList
domains={domains}
globalBlocklistCount={state.blocklistCount}
open={vipOpen}
onToggle={() => setVipOpen((v) => !v)}
colors={colors}
@ -358,8 +349,8 @@ export default function BlockerScreen() {
setAddSheetOpen(false);
refreshDomains();
}}
onAdd={async (pattern, kind) => {
const result = await addDomain(pattern, kind);
onAdd={async (pattern, kind, opts) => {
const result = await addDomain(pattern, kind, opts);
if (result.ok) {
syncWebContent();
const sync = await syncBlocklist();

View File

@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next';
import {
isValidDomain,
normalizeDomain,
type AddDomainResult,
type Tier,
} from '../../hooks/useCustomDomains';
import { useColors, type ColorScheme } from '../../lib/theme';
@ -22,7 +23,11 @@ type Props = {
visible: boolean;
tier: Tier;
onClose: () => void;
onAdd: (pattern: string, kind?: 'web' | 'mail') => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>;
onAdd: (
pattern: string,
kind?: 'web' | 'mail',
opts?: { addToVip?: boolean },
) => Promise<AddDomainResult>;
};
function detectKind(input: string): 'web' | 'mail' | null {
@ -49,11 +54,15 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
const [error, setError] = useState<string | null>(null);
// User-Override über den Auto-Detect. null = follow auto-detect, sonst forced.
const [kindOverride, setKindOverride] = useState<'web' | 'mail' | null>(null);
// Fall 3: Domain ist in Layer 1, aber nicht in der kuratierten VIP. Hält die
// Domain fest, für die der User entscheiden soll, ob er sie zur VIP nimmt.
const [vipPrompt, setVipPrompt] = useState<string | null>(null);
const detected = detectKind(input);
const kind: 'web' | 'mail' | null = kindOverride ?? detected;
const normalizedWeb = kind === 'web' ? normalizeDomain(input) : '';
const normalizedMail = kind === 'mail' ? mailDomain(input) : '';
const inVipMode = vipPrompt !== null;
// Reset override sobald User komplett neuen Input tippt
useEffect(() => {
@ -65,6 +74,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
setKindOverride(null);
setConfirmPermanent(false);
setError(null);
setVipPrompt(null);
onClose();
}
@ -81,43 +91,63 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
const pattern = kind === 'web' ? normalizeDomain(input) : normalizedMail;
// Pass kind explicitly — we've already stripped the local-part for mail,
// so the backend's auto-detect (which keys on the "@" character) can no
// longer infer the type from the pattern alone. Without this hint a
// "info@only4-subscribers.com" entry would land as type=web because
// the @ disappeared during the strip.
// longer infer the type from the pattern alone.
const result = await onAdd(pattern, kind === 'mail' ? 'mail' : 'web');
setAdding(false);
if (result.ok) {
close();
return;
}
// Fall 3: in Layer 1, nicht in kuratierter VIP → User entscheidet
if (result.inGlobalNotVip) {
setVipPrompt(pattern);
return;
}
// Fall 2: schon voll geschützt (Layer 1 + kuratierte VIP)
if (result.alreadyProtected) {
setError(t('blocker.add_sheet_already_protected', { domain: pattern }));
return;
}
if (result.alreadyGlobal) {
setError(t('blocker.add_sheet_already_global', { domain: pattern }));
} else {
const raw = (result.error ?? '').toLowerCase();
if (raw.includes('web_limit_reached')) {
setError(t('blocker.error_web_limit_reached'));
} else if (raw.includes('mail_limit_reached')) {
setError(t('blocker.error_mail_limit_reached'));
} else if (raw === 'limit_reached' || raw.includes('limit_reached')) {
// Client-side tier.atLimit Reject (combined web+mail). Bucket-spezifisch
// wäre genauer aber der Generic-Limit-Hinweis reicht für jetzt.
setError(
kind === 'mail'
? t('blocker.error_mail_limit_reached')
: t('blocker.error_web_limit_reached'),
);
} else if (raw.includes('invalid_mail_domain') || raw.includes('display_name_not_supported')) {
setError(t('blocker.error_invalid_mail'));
} else if (raw.includes('invalid_domain') || raw.includes('invalid_pattern')) {
setError(t('blocker.error_invalid_input'));
} else if (raw.includes('eintrag bereits vorhanden') || raw.includes('duplicate')) {
setError(t('blocker.error_duplicate'));
} else {
// Letzter Fallback: niemals raw JSON anzeigen. Wenn message-Feld da war, kommt's
// sauber als String an — sonst generic Fehler.
setError(t('blocker.add_sheet_add_failed'));
}
return;
}
const raw = (result.error ?? '').toLowerCase();
if (raw.includes('web_limit_reached')) {
setError(t('blocker.error_web_limit_reached'));
} else if (raw.includes('mail_limit_reached')) {
setError(t('blocker.error_mail_limit_reached'));
} else if (raw === 'limit_reached' || raw.includes('limit_reached')) {
setError(
kind === 'mail'
? t('blocker.error_mail_limit_reached')
: t('blocker.error_web_limit_reached'),
);
} else if (raw.includes('invalid_mail_domain') || raw.includes('display_name_not_supported')) {
setError(t('blocker.error_invalid_mail'));
} else if (raw.includes('invalid_domain') || raw.includes('invalid_pattern')) {
setError(t('blocker.error_invalid_input'));
} else if (raw.includes('eintrag bereits vorhanden') || raw.includes('duplicate')) {
setError(t('blocker.error_duplicate'));
} else {
setError(t('blocker.add_sheet_add_failed'));
}
}
// Fall 3 bestätigt: Domain als VIP-Zweitschutz aufnehmen (Backend speichert
// sie als 'approved' — kein Slot, erscheint nur in der VIP-Liste).
async function handleConfirmVip() {
if (!vipPrompt || adding) return;
setAdding(true);
setError(null);
const result = await onAdd(vipPrompt, 'web', { addToVip: true });
setAdding(false);
if (result.ok) {
close();
return;
}
setVipPrompt(null);
setError(t('blocker.add_sheet_add_failed'));
}
const warningText =
@ -125,7 +155,13 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
? t('blocker.add_sheet_warning_free')
: t('blocker.add_sheet_warning_pro');
const canSubmit = isInputValid() && confirmPermanent && !adding;
const canSubmitNormal = isInputValid() && confirmPermanent && !adding;
const ctaEnabled = inVipMode ? !adding : canSubmitNormal;
const ctaColor = inVipMode
? colors.brandOrange
: canSubmitNormal
? '#dc2626'
: '#d4d4d4';
return (
<FormSheet
@ -147,7 +183,12 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
</Text>
<TextInput
value={input}
onChangeText={(v) => { setInput(v); setError(null); }}
onChangeText={(v) => {
setInput(v);
setError(null);
setVipPrompt(null);
}}
editable={!inVipMode}
placeholder={t('blocker.add_sheet_placeholder')}
placeholderTextColor={colors.textMuted}
keyboardType="email-address"
@ -162,6 +203,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
color: colors.text,
borderWidth: 1,
borderColor: error ? '#dc2626' : colors.border,
opacity: inVipMode ? 0.6 : 1,
}}
/>
{error && (
@ -171,144 +213,175 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
)}
</View>
{/* Help text */}
<View
style={{
flexDirection: 'row',
gap: 8,
padding: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
}}
>
<Ionicons
name="information-circle-outline"
size={16}
color={colors.textMuted}
style={{ marginTop: 1 }}
/>
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
lineHeight: 17,
}}
>
{t('blocker.add_sheet_help')}
</Text>
</View>
{/* Preview card */}
<PreviewCard
kind={kind}
normalizedWeb={normalizedWeb}
normalizedMail={normalizedMail}
placeholder={t('blocker.add_sheet_placeholder')}
colors={colors}
t={t}
/>
{/* Override toggle — User kann Auto-Detect korrigieren falls falsch erkannt */}
{detected !== null && (
<TouchableOpacity
onPress={() => setKindOverride(kind === 'mail' ? 'web' : 'mail')}
activeOpacity={0.7}
{inVipMode ? (
/* Fall 3: Erklärungs-Karte Domain ist in Layer 1, kann zusätzlich
in den VIP-Zweitschutz aufgenommen werden. */
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 12,
paddingVertical: 10,
padding: 12,
backgroundColor: '#eff6ff',
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
borderColor: '#bfdbfe',
}}
>
<Ionicons
name={kind === 'mail' ? 'checkbox' : 'square-outline'}
size={20}
color={kind === 'mail' ? colors.brandOrange : colors.textMuted}
/>
<Ionicons name="shield-half" size={18} color="#2563eb" style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: colors.text,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#1e3a8a',
lineHeight: 17,
}}
>
{t('blocker.kind_override_label')}
{t('blocker.add_sheet_in_global_not_vip', { domain: vipPrompt ?? '' })}
</Text>
</TouchableOpacity>
)}
{/* Warning card */}
<View
style={{
flexDirection: 'row',
gap: 10,
padding: 12,
backgroundColor: '#fef3c7',
borderRadius: 12,
borderWidth: 1,
borderColor: '#fcd34d',
}}
>
<Ionicons name="lock-closed" size={18} color="#92400e" style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#92400e',
lineHeight: 17,
}}
>
{warningText}
</Text>
</View>
{/* Confirm checkbox */}
<TouchableOpacity
onPress={() => setConfirmPermanent((v) => !v)}
activeOpacity={0.7}
style={{
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
paddingVertical: 4,
}}
>
<View
style={{
width: 22,
height: 22,
borderRadius: 6,
borderWidth: 1.5,
borderColor: confirmPermanent ? colors.success : colors.border,
backgroundColor: confirmPermanent ? colors.success : colors.bg,
alignItems: 'center',
justifyContent: 'center',
marginTop: 1,
}}
>
{confirmPermanent && <Ionicons name="checkmark" size={16} color="#fff" />}
</View>
<Text
style={{
flex: 1,
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: colors.text,
lineHeight: 18,
}}
>
{t('blocker.add_sheet_confirm_permanent')}
</Text>
</TouchableOpacity>
) : (
<>
{/* Help text */}
<View
style={{
flexDirection: 'row',
gap: 8,
padding: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
}}
>
<Ionicons
name="information-circle-outline"
size={16}
color={colors.textMuted}
style={{ marginTop: 1 }}
/>
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
lineHeight: 17,
}}
>
{t('blocker.add_sheet_help')}
</Text>
</View>
{/* Preview card */}
<PreviewCard
kind={kind}
normalizedWeb={normalizedWeb}
normalizedMail={normalizedMail}
placeholder={t('blocker.add_sheet_placeholder')}
colors={colors}
t={t}
/>
{/* Override toggle — User kann Auto-Detect korrigieren falls falsch erkannt */}
{detected !== null && (
<TouchableOpacity
onPress={() => setKindOverride(kind === 'mail' ? 'web' : 'mail')}
activeOpacity={0.7}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
}}
>
<Ionicons
name={kind === 'mail' ? 'checkbox' : 'square-outline'}
size={20}
color={kind === 'mail' ? colors.brandOrange : colors.textMuted}
/>
<Text
style={{
flex: 1,
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: colors.text,
}}
>
{t('blocker.kind_override_label')}
</Text>
</TouchableOpacity>
)}
{/* Warning card */}
<View
style={{
flexDirection: 'row',
gap: 10,
padding: 12,
backgroundColor: '#fef3c7',
borderRadius: 12,
borderWidth: 1,
borderColor: '#fcd34d',
}}
>
<Ionicons name="lock-closed" size={18} color="#92400e" style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#92400e',
lineHeight: 17,
}}
>
{warningText}
</Text>
</View>
{/* Confirm checkbox */}
<TouchableOpacity
onPress={() => setConfirmPermanent((v) => !v)}
activeOpacity={0.7}
style={{
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
paddingVertical: 4,
}}
>
<View
style={{
width: 22,
height: 22,
borderRadius: 6,
borderWidth: 1.5,
borderColor: confirmPermanent ? colors.success : colors.border,
backgroundColor: confirmPermanent ? colors.success : colors.bg,
alignItems: 'center',
justifyContent: 'center',
marginTop: 1,
}}
>
{confirmPermanent && <Ionicons name="checkmark" size={16} color="#fff" />}
</View>
<Text
style={{
flex: 1,
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: colors.text,
lineHeight: 18,
}}
>
{t('blocker.add_sheet_confirm_permanent')}
</Text>
</TouchableOpacity>
</>
)}
{/* Buttons */}
<View style={{ flexDirection: 'row', gap: 10, marginTop: 4 }}>
@ -330,14 +403,14 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
</TouchableOpacity>
<TouchableOpacity
onPress={handleAdd}
disabled={!canSubmit}
onPress={inVipMode ? handleConfirmVip : handleAdd}
disabled={!ctaEnabled}
activeOpacity={0.85}
style={{ flex: 2 }}
>
<View
style={{
backgroundColor: canSubmit ? '#dc2626' : '#d4d4d4',
backgroundColor: ctaColor,
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
@ -347,7 +420,9 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('blocker.add_sheet_cta')}
{inVipMode
? t('blocker.add_sheet_add_to_vip_cta')
: t('blocker.add_sheet_cta')}
</Text>
)}
</View>

View File

@ -1,225 +0,0 @@
import { View, Text, TouchableOpacity, ScrollView, ActionSheetIOS, Platform, Alert } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet';
type Props = {
visible: boolean;
domain: string;
onClose: () => void;
onConfirmRemove: () => Promise<void>;
};
/**
* 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 (
<FormSheet
visible={visible}
onClose={onClose}
title={t('blocker.remove_domain_sheet_heading')}
initialHeightPct={0.58}
minHeightPct={0.3}
safeAreaBottom={false}
>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, paddingBottom: Math.max(insets.bottom, 12) + 24, gap: 18 }}
>
<Text style={{ fontSize: 22, fontFamily: 'Nunito_800ExtraBold', color: colors.text }}>
{t('blocker.remove_domain_title')}
</Text>
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<Ionicons name="globe-outline" size={16} color={colors.textMuted} />
<Text
numberOfLines={1}
style={{ fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: colors.text, flex: 1 }}
>
{domain}
</Text>
</View>
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
lineHeight: 22,
}}
>
{t('blocker.remove_domain_intro')}
</Text>
<View style={{ gap: 12 }}>
<BulletRow
icon="shield-outline"
title={t('blocker.remove_domain_bullet1_title')}
text={t('blocker.remove_domain_bullet1_text')}
/>
<BulletRow
icon="alert-circle-outline"
title={t('blocker.remove_domain_bullet2_title')}
text={t('blocker.remove_domain_bullet2_text')}
/>
</View>
<View style={{ height: 8 }} />
<TouchableOpacity onPress={onClose} activeOpacity={0.85}>
<View
style={{
backgroundColor: colors.success,
borderRadius: 14,
paddingVertical: 16,
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
}}
>
<Ionicons name="shield-checkmark" size={18} color="#fff" />
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('blocker.remove_domain_keep_cta')}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={showFinalConfirm}
disabled={submitting}
hitSlop={8}
activeOpacity={0.5}
style={{
opacity: submitting ? 0.5 : 1,
alignSelf: 'center',
paddingVertical: 12,
}}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: colors.error,
}}
>
{submitting ? t('blocker.remove_domain_removing') : t('blocker.remove_domain_remove_anyway')}
</Text>
</TouchableOpacity>
</ScrollView>
</FormSheet>
);
}
function BulletRow({
icon,
title,
text,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
title: string;
text: string;
}) {
const colors = useColors();
return (
<View style={{ flexDirection: 'row', gap: 12 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={18} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{title}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 2,
lineHeight: 17,
}}
>
{text}
</Text>
</View>
</View>
);
}

View File

@ -1,21 +1,24 @@
import { useRef, useEffect, useState, useMemo } from 'react';
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 { RemoveDomainSheet } from './RemoveDomainSheet';
import type { CustomDomain } from '../../hooks/useCustomDomains';
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;
onRemoveDomain: (id: string) => Promise<void>;
onSubmitDomain: (id: string) => Promise<{ ok: boolean }>;
colors: ColorScheme;
};
@ -23,15 +26,19 @@ type MyFiltersProps = {
* "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). Entfernen via RemoveDomainSheet.
* 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,
onRemoveDomain,
onSubmitDomain,
colors,
}: MyFiltersProps) {
const { t } = useTranslation();
@ -127,7 +134,7 @@ export function MyFiltersList({
{visibleDomains.length === 0 ? (
<MyFiltersEmptyState onAddPress={onAddPress} colors={colors} />
) : (
<FilterTilesGrid domains={visibleDomains} onRemoveDomain={onRemoveDomain} />
<FilterTilesGrid domains={visibleDomains} tier={tier} onSubmit={onSubmitDomain} />
)}
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 6, paddingTop: 2 }}>
@ -184,15 +191,17 @@ function MyFiltersEmptyState({ onAddPress, colors }: { onAddPress: () => void; c
function FilterTilesGrid({
domains,
onRemoveDomain,
tier,
onSubmit,
}: {
domains: CustomDomain[];
onRemoveDomain: (id: string) => Promise<void>;
tier: Tier;
onSubmit: (id: string) => Promise<{ ok: boolean }>;
}) {
return (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', rowGap: 10, columnGap: 8 }}>
{domains.map((d) => (
<FilterTile key={d.id} domain={d} onRemove={onRemoveDomain} />
<FilterTile key={d.id} domain={d} tier={tier} onSubmit={onSubmit} />
))}
</View>
);
@ -200,19 +209,31 @@ function FilterTilesGrid({
function FilterTile({
domain,
onRemove,
tier,
onSubmit,
}: {
domain: CustomDomain;
onRemove: (id: string) => Promise<void>;
tier: Tier;
onSubmit: (id: string) => Promise<{ ok: boolean }>;
}) {
const { t } = useTranslation();
const colors = useColors();
const [imgError, setImgError] = useState(false);
const [removeSheetOpen, setRemoveSheetOpen] = useState(false);
const [removing, setRemoving] = 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) {
@ -230,12 +251,27 @@ function FilterTile({
}
})();
async function handleConfirmRemove() {
setRemoving(true);
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 {
await onRemove(domain.id);
const result = await onSubmit(domain.id);
if (result.ok) setSuccessVisible(true);
} finally {
setRemoving(false);
setSubmitting(false);
}
}
@ -251,7 +287,7 @@ function FilterTile({
width: '31%',
minHeight: 118,
gap: 4,
opacity: removing ? 0.4 : 1,
opacity: submitting ? 0.5 : 1,
}}
>
{/* Type + Status badge row */}
@ -333,33 +369,65 @@ function FilterTile({
</Text>
</View>
{/* Remove button */}
<TouchableOpacity
onPress={() => setRemoveSheetOpen(true)}
disabled={removing}
activeOpacity={0.65}
style={{
height: 26,
borderRadius: 6,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
}}
>
{removing ? (
<ActivityIndicator size="small" color={colors.textMuted} />
) : (
<Ionicons name="trash-outline" size={13} color={colors.textMuted} />
)}
</TouchableOpacity>
{/* Bottom slot — Freigabe / Erneut / in Prüfung. Immer 26px hoch,
damit alle Kacheln gleich hoch bleiben. */}
<View style={{ height: 26 }}>
{domain.status === 'submitted' ? (
<View
style={{
flex: 1,
backgroundColor: colors.warning,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 9, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{isLegend ? t('blocker.domain_btn_rebreak_prueft') : t('blocker.domain_btn_in_abstimmung')}
</Text>
</View>
) : canSubmit ? (
<TouchableOpacity
onPress={() => setConfirmVisible(true)}
disabled={submitting}
activeOpacity={0.65}
style={{
flex: 1,
borderRadius: 6,
borderWidth: 1,
borderColor: btnColor,
alignItems: 'center',
justifyContent: 'center',
}}
>
{submitting ? (
<ActivityIndicator size="small" color={btnColor} />
) : (
<Text style={{ fontSize: 10, fontFamily: 'Nunito_600SemiBold', color: btnColor }}>
{isResubmit ? t('blocker.domain_btn_erneut') : t('blocker.domain_btn_freigeben')}
</Text>
)}
</TouchableOpacity>
) : null}
</View>
</View>
<RemoveDomainSheet
visible={removeSheetOpen}
domain={stripped}
onClose={() => setRemoveSheetOpen(false)}
onConfirmRemove={handleConfirmRemove}
<ConfirmAlert
visible={confirmVisible}
title={confirmTitle}
message={confirmMessage}
confirmLabel={t('blocker.domain_btn_freigeben')}
icon={isLegend ? 'shield-checkmark' : 'people'}
iconColor="#f59e0b"
onConfirm={handleConfirm}
onCancel={() => setConfirmVisible(false)}
/>
<SuccessAlert
visible={successVisible}
title={isLegend ? t('blocker.domain_success_legend_title') : t('blocker.domain_success_community_title')}
message={isLegend ? t('blocker.domain_success_legend_message') : t('blocker.domain_success_community_message')}
onClose={() => setSuccessVisible(false)}
/>
</>
);
@ -369,7 +437,6 @@ function FilterTile({
type VipListProps = {
domains: CustomDomain[];
globalBlocklistCount: number;
open: boolean;
onToggle: () => void;
colors: ColorScheme;
@ -378,20 +445,50 @@ type VipListProps = {
/**
* "VIP-Liste" Zweitschutz-Sektion. Collapsible.
*
* Zeigt die zusammengesetzte VIP-Layer-2-Liste:
* - Eigene Web-Domains des Users (für Family-Controls / webContent-Sync)
* - Hinweis auf den globalen kuratierten Teil (nicht editierbar)
* 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.
* Diese Liste greift als Zweitschutz, falls Layer 1 (VPN/URL-Filter) ein
* technisches Problem hat.
*/
export function VipDomainList({ domains, globalBlocklistCount, open, onToggle, colors }: VipListProps) {
export function VipDomainList({ domains, open, onToggle, colors }: VipListProps) {
const { t } = useTranslation();
const { domains: vipList, loading, refetch } = useWebContentDomains();
const webDomains = useMemo(
() => domains.filter((d) => (d.type === 'web' || !d.type) && d.status !== 'approved'),
// 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<string, DomainStatus>();
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 (
<View
@ -438,57 +535,116 @@ export function VipDomainList({ domains, globalBlocklistCount, open, onToggle, c
{t('blocker.vip_layer2_desc')}
</Text>
{webDomains.length > 0 && (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', rowGap: 8, columnGap: 6 }}>
{webDomains.map((d) => (
<VipReadonlyChip key={d.id} domain={d} colors={colors} />
))}
{loading && vipList === null ? (
<View style={{ paddingVertical: 20, alignItems: 'center' }}>
<ActivityIndicator size="small" color={colors.textMuted} />
</View>
) : (
<>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
}}
>
{t('blocker.vip_layer2_count', { count: list.length })}
</Text>
{list.length > 0 && (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', rowGap: 11, columnGap: 9 }}>
{list.map((d) => (
<VipReadonlyChip
key={d}
domain={d}
status={customStatusMap.get(d)}
colors={colors}
/>
))}
</View>
)}
</>
)}
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 6, paddingTop: 2 }}>
<Ionicons name="shield-checkmark-outline" size={13} color={colors.textMuted} style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
lineHeight: 16,
}}
>
{t('blocker.vip_layer2_global_hint', { count: globalBlocklistCount })}
</Text>
</View>
</View>
)}
</View>
);
}
function VipReadonlyChip({ domain, colors }: { domain: CustomDomain; colors: ColorScheme }) {
const stripped = domain.domain.replace(/^www\./, '');
/**
* 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 (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 999,
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
}}
>
<Ionicons name="globe-outline" size={11} color={colors.textMuted} />
<Text
numberOfLines={1}
style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.text, maxWidth: 120 }}
<View style={{ position: 'relative' }}>
{isPending && (
<Animated.View
pointerEvents="none"
style={{
position: 'absolute',
top: -3,
left: -3,
right: -3,
bottom: -3,
borderRadius: 999,
borderWidth: 2,
borderColor: colors.brandOrange,
opacity: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.15, 0.95] }),
}}
/>
)}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 999,
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: isCustom ? colors.brandOrange : colors.border,
}}
>
{stripped}
</Text>
<Ionicons
name={isCustom ? 'star' : 'globe-outline'}
size={11}
color={isCustom ? colors.brandOrange : colors.textMuted}
/>
<Text
numberOfLines={1}
style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.text, maxWidth: 120 }}
>
{stripped}
</Text>
</View>
</View>
);
}

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { apiFetch } from '../lib/api';
import { resolveVipCountry } from './useWebContentDomains';
export type DomainStatus = 'active' | 'submitted' | 'approved' | 'rejected';
@ -17,6 +18,24 @@ export type CustomDomain = {
export type Plan = 'free' | 'pro' | 'legend';
/**
* Ergebnis von addDomain. Neben `ok` transportiert es die 3-Fall-Logik des
* Backends für Web-Domains gegen Layer 1 (global) + Layer 2 (kuratierte VIP):
* alreadyGlobal Mail-Pattern schon global kein Slot verbrannt
* alreadyProtected Web-Domain in global UND kuratierter VIP nichts zu tun
* inGlobalNotVip Web-Domain in global, NICHT in kuratierter VIP
* User kann sie per addToVip-Re-Request zur VIP nehmen
* addedToVip Domain wurde als VIP-Zweitschutz ('approved') gespeichert
*/
export type AddDomainResult = {
ok: boolean;
error?: string;
alreadyGlobal?: boolean;
alreadyProtected?: boolean;
inGlobalNotVip?: boolean;
addedToVip?: boolean;
};
export type Tier = {
plan: Plan;
domainLimit: number; // free=5, pro=5, legend=10
@ -60,7 +79,11 @@ export type UseCustomDomainsReturn = {
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
addDomain: (pattern: string, kind?: 'web' | 'mail') => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>;
addDomain: (
pattern: string,
kind?: 'web' | 'mail',
opts?: { addToVip?: boolean },
) => Promise<AddDomainResult>;
submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
removeDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
/** Live-Validate (regex) ob string gültiger Domain-Name ist. */
@ -141,14 +164,19 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
}, [fetchDomains]);
const addDomain = useCallback(
async (input: string, kind?: 'web' | 'mail') => {
async (
input: string,
kind?: 'web' | 'mail',
opts?: { addToVip?: boolean },
): Promise<AddDomainResult> => {
const resolvedKind: 'web' | 'mail' = kind ?? (input.includes('@') ? 'mail' : 'web');
if (resolvedKind === 'web' && !isValidDomain(input)) return { ok: false, error: 'invalid_domain' };
if (resolvedKind === 'mail' && !input.trim()) return { ok: false, error: 'invalid_pattern' };
// Per-Bucket-Limit-Check via Backend-counts/limits (Single Source of Truth).
// Wenn API noch keine counts/limits geliefert hat (Legacy-Response) → skip,
// Backend rejected dann mit WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED.
if (apiCounts && apiLimits) {
// Entfällt bei addToVip: 'approved'-Einträge belegen keinen Slot.
if (!opts?.addToVip && apiCounts && apiLimits) {
const bucket = resolvedKind;
const used = apiCounts[bucket] ?? 0;
const cap = apiLimits[bucket] ?? Infinity;
@ -160,23 +188,26 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
}
}
const pattern = resolvedKind === 'web' ? normalizeDomain(input) : input.trim();
const body: Record<string, string> = { pattern };
const body: Record<string, string | boolean> = { pattern };
if (kind !== undefined) body.kind = kind;
// Land mitschicken — Backend prüft die kuratierte VIP-Liste des Landes.
if (resolvedKind === 'web') body.country = resolveVipCountry();
if (opts?.addToVip) body.addToVip = true;
try {
const res = await apiFetch<any>('/api/custom-domains', {
method: 'POST',
body,
});
if (res?.alreadyGlobal) {
return { ok: false, alreadyGlobal: true };
}
if (res?.alreadyGlobal) return { ok: false, alreadyGlobal: true };
if (res?.alreadyProtected) return { ok: false, alreadyProtected: true };
if (res?.inGlobalNotVip) return { ok: false, inGlobalNotVip: true };
await fetchDomains();
return { ok: true };
return { ok: true, addedToVip: res?.addedToVip === true };
} catch (e: any) {
return { ok: false, error: e?.message ?? 'add_failed' };
}
},
[plan, domains, fetchDomains],
[apiCounts, apiLimits, fetchDomains],
);
const submitDomain = useCallback(

View File

@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Localization from 'expo-localization';
import { apiFetch } from '../lib/api';
/**
* Landabhängige VIP-Layer-2-Liste.
*
* Das Backend (`GET /api/protection/webcontent-domains`) liefert die
* komponierte Liste pro Land (Custom-Domains gekappt auf 30 + kuratierte
* Auffüllung, dedup, Cap 50). Hier wählen wir die Country-Slice nach der
* GERÄTE-Region aus dasselbe Signal, das auch der native iOS-webContent-
* Filter nutzt (`Locale.current.region`).
*/
const VIP_COUNTRIES = ['DE', 'GB', 'FR'] as const;
export type VipCountry = (typeof VIP_COUNTRIES)[number];
/** Geräte-Region → unterstützter VIP-Ländercode. Fallback DE. */
export function resolveVipCountry(): VipCountry {
const region = Localization.getLocales()[0]?.regionCode?.toUpperCase();
if (region && (VIP_COUNTRIES as readonly string[]).includes(region)) {
return region as VipCountry;
}
return 'DE';
}
type WebContentResponse = { _meta?: unknown } & Record<string, string[]>;
export function useWebContentDomains() {
const [country] = useState<VipCountry>(resolveVipCountry);
const [domains, setDomains] = useState<string[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const refetch = useCallback(async () => {
try {
const res = await apiFetch<WebContentResponse>(
'/api/protection/webcontent-domains',
);
if (!mountedRef.current) return;
const list = Array.isArray(res?.[country]) ? res[country] : [];
setDomains(list);
setError(null);
} catch (e: any) {
if (!mountedRef.current) return;
console.warn('[useWebContentDomains] fetch failed:', e?.message ?? e);
setError(e?.message ?? 'fetch_failed');
} finally {
if (mountedRef.current) setLoading(false);
}
}, [country]);
useEffect(() => {
refetch();
}, [refetch]);
return { country, domains, loading, error, refetch };
}

View File

@ -212,6 +212,9 @@
"add_sheet_confirm_permanent": "Ich verstehe dass diese Domain permanent ist.",
"add_sheet_add_failed": "Hinzufügen fehlgeschlagen.",
"add_sheet_already_global": "%{domain} steht bereits in der globalen Sperrliste — kein Slot nötig.",
"add_sheet_already_protected": "%{domain} ist bereits voll geschützt — in der Sperrliste UND in deinem VIP-Zweitschutz. Nichts zu tun.",
"add_sheet_in_global_not_vip": "%{domain} ist schon in unserer Sperrliste (Layer 1). Du kannst die Seite zusätzlich in deinen VIP-Zweitschutz aufnehmen — dann bleibt sie gesperrt, selbst wenn Layer 1 mal aus ist. Kein Slot wird verbraucht.",
"add_sheet_add_to_vip_cta": "Zur VIP-Liste hinzufügen",
"cooldown_banner_title": "Cooldown läuft",
"deactivation_actionsheet_title": "24-Stunden-Cooldown starten?",
"deactivation_actionsheet_message": "Schutz bleibt während dieser Zeit aktiv. Du kannst jederzeit abbrechen.",
@ -387,6 +390,7 @@
"vip_layer2_title": "VIP-Liste",
"vip_layer2_desc": "Zweitschutz: Diese Liste greift, falls der URL-Filter (Layer 1) ein technisches Problem hat. Sie enthält deine eigenen Domains plus einen kuratierten globalen Anteil.",
"vip_layer2_global_hint": "+ %{count} bekannte Glücksspielseiten automatisch geschützt",
"vip_layer2_count": "%{count} Seiten in deiner VIP-Liste",
"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.",

View File

@ -212,6 +212,9 @@
"add_sheet_confirm_permanent": "I understand this domain is permanent.",
"add_sheet_add_failed": "Failed to add domain.",
"add_sheet_already_global": "%{domain} is already on the global blocklist — no slot needed.",
"add_sheet_already_protected": "%{domain} is already fully protected — on the blocklist AND in your VIP second layer. Nothing to do.",
"add_sheet_in_global_not_vip": "%{domain} is already on our blocklist (Layer 1). You can additionally add it to your VIP second layer — then it stays blocked even if Layer 1 is ever off. No slot is used.",
"add_sheet_add_to_vip_cta": "Add to VIP list",
"cooldown_banner_title": "Cooldown running",
"deactivation_actionsheet_title": "Start 24-hour cooldown?",
"deactivation_actionsheet_message": "Protection stays active during this time. You can cancel anytime.",
@ -387,6 +390,7 @@
"vip_layer2_title": "VIP List",
"vip_layer2_desc": "Second-layer protection: this list activates if the URL filter (Layer 1) has a technical issue. It includes your custom domains plus a curated global portion.",
"vip_layer2_global_hint": "+ %{count} known gambling sites automatically protected",
"vip_layer2_count": "%{count} sites in your VIP list",
"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.",

View File

@ -1,8 +1,8 @@
{
"_comment": "STARTER — kuratierte Starter-Liste der bekanntesten Gambling-Domains pro Land. NICHT die Endliste. Die finale, traffic-rangbasierte Kuratierung (via Similarweb-Ranking / GGL-Whitelist für DE) ist noch offen. Apple-Hartlimit: max. 50 Domains pro Land — diese Grenze darf NIE überschritten werden. Schlüssel = ISO-3166-1-alpha-2-Ländercode (Locale.current.region). Werte = registrierbare Domains ohne Schema/Subdomain (ManagedSettings WebDomain matched die Domain inkl. Subdomains).",
"_meta": {
"version": 1,
"updatedAt": "2026-05-21",
"version": 2,
"updatedAt": "2026-05-22",
"maxDomainsPerCountry": 50,
"status": "starter"
},
@ -11,24 +11,26 @@
"tipico.com",
"bwin.de",
"bwin.com",
"lotto.de",
"lotto24.de",
"interwetten.de",
"interwetten.com",
"betano.de",
"bet-at-home.com",
"sportwetten.de",
"merkur-bets.de",
"merkurbets.de",
"happybet.de",
"neobet.de",
"betano.com",
"winamax.de",
"bet-at-home.com",
"betway.de",
"admiralbet.de",
"merkur-bets.de",
"happybet.de",
"neobet.de",
"sportwetten.de",
"oddset.de",
"lottohelden.de",
"lotto.de",
"lotto24.de",
"jackpot.de",
"drueckglueck.de",
"wunderino.com",
"merkurbets.de",
"loewen-play.de",
"merkur24.com",
"casino.de",
@ -36,7 +38,11 @@
"betsson.de",
"leovegas.de",
"lapalingo.com",
"sunmaker.de"
"sunmaker.de",
"pokerstars.de",
"lottoland.com",
"jackpotpiraten.de",
"crazybuzzer.de"
],
"GB": [
"bet365.com",

View File

@ -8,10 +8,21 @@ import {
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
import { usePrisma } from "../../utils/prisma";
import gamblingDomains from "../../data/gambling-domains.json";
// Regex: Domain muss mindestens eine TLD haben (z.B. "casino.de", "x.co.uk")
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
// Kuratierte Layer-2-VIP-Listen pro Land (gambling-domains.json).
const CURATED_LISTS = gamblingDomains as unknown as Record<string, string[]>;
const VIP_COUNTRIES = ["DE", "GB", "FR"] as const;
/** Client-`country` (Geräte-Region) → unterstützter VIP-Ländercode. Fallback DE. */
function resolveVipCountry(raw: unknown): string {
const c = typeof raw === "string" ? raw.toUpperCase() : "";
return (VIP_COUNTRIES as readonly string[]).includes(c) ? c : "DE";
}
/**
* Leitet Frontend-`kind` auf internen `CustomDomainType` ab.
*
@ -168,55 +179,83 @@ export default defineEventHandler(async (event) => {
});
}
// Pre-check NUR für Mail-Typen: ist die Domain schon in der globalen
// Blocklist? Dann keinen Slot verbrennen — der Mail-Daemon scannt dieselbe
// Blocklist, ein Custom-Slot wäre redundant.
//
// Für `web` BEWUSST NICHT: Web-Custom-Domains speisen die Layer-2-VIP-Liste
// (webContent / Family Controls) — eine SEPARATE Schicht von der globalen
// Layer-1-Blocklist (URL-Filter / VPN). Eine Domain in Layer 1 ist NICHT
// automatisch in der Layer-2-VIP-50; und Layer 2 ist gerade das Netz für den
// Fall, dass Layer 1 deaktiviert wird. Global gelistete Domains müssen also
// in die VIP aufgenommen werden können.
if (type !== "web") {
const db = usePrisma();
const globalMatch = await db.blocklistDomain.findFirst({
where: { domain: value, isActive: true },
select: { domain: true },
});
if (globalMatch) {
return { alreadyGlobal: true, domain: value };
}
// Ist die Domain schon in der globalen Layer-1-Blocklist?
const db = usePrisma();
const globalMatch = await db.blocklistDomain.findFirst({
where: { domain: value, isActive: true },
select: { domain: true },
});
const inGlobal = !!globalMatch;
// ─── Mail-Typen: schon global = kein Slot verbrennen ───────────────────
// Der Mail-Daemon scannt dieselbe Blocklist — ein Custom-Slot wäre redundant.
if (type !== "web" && inGlobal) {
return { alreadyGlobal: true, domain: value };
}
// Per-type Slot-Limit prüfen
const profile = await getProfile(user.id);
const limits = getPlanLimits(profile?.plan ?? "free");
// ─── Web: 3-Fall-Check gegen Layer 1 (global) + Layer 2 (kuratierte VIP) ──
//
// Layer 1 (VPN/URL-Filter) = globale Blocklist. Layer 2 (webContent/VIP) =
// kuratierte gambling-domains.json + eigene Custom-Domains; greift als
// Zweitschutz, falls Layer 1 aus ist.
// 1. weder global noch kuratiert → normaler Custom-Eintrag ('active')
// 2. global UND kuratiert → schon komplett geschützt, kein Slot
// 3. global, aber NICHT kuratiert → Hinweis an User; bei addToVip=true wird
// die Domain als 'approved' gespeichert (kein Slot, erscheint nur in der
// VIP-Liste — 'approved' ist semantisch korrekt: sie IST in Layer 1).
let webAddAsApproved = false;
if (type === "web") {
const country = resolveVipCountry(body?.country);
const curatedList: string[] = CURATED_LISTS[country] ?? [];
const inVipCurated = curatedList.includes(value);
const addToVip = body?.addToVip === true;
// Welcher Bucket?
if (inGlobal && !addToVip) {
return inVipCurated
? { alreadyProtected: true, domain: value }
: { inGlobalNotVip: true, domain: value };
}
if (inGlobal && addToVip) {
webAddAsApproved = true;
}
// !inGlobal → normaler Add unten
}
// Per-type Slot-Limit prüfen — entfällt für webAddAsApproved (approved
// belegt keinen Slot).
const bucket: "web" | "mail" = type === "web" ? "web" : "mail";
const bucketLimit = limits.customDomains[bucket];
if (!webAddAsApproved) {
const profile = await getProfile(user.id);
const limits = getPlanLimits(profile?.plan ?? "free");
const bucketLimit = limits.customDomains[bucket];
if (bucketLimit !== Infinity) {
const split = await countActiveCustomDomainsSplit(user.id);
const currentCount = split[bucket];
if (currentCount >= bucketLimit) {
const errorCode = bucket === "web" ? "WEB_LIMIT_REACHED" : "MAIL_LIMIT_REACHED";
throw createError({
statusCode: 403,
data: {
error: errorCode,
resource: "custom_domains",
bucket,
current: currentCount,
limit: bucketLimit,
},
});
if (bucketLimit !== Infinity) {
const split = await countActiveCustomDomainsSplit(user.id);
const currentCount = split[bucket];
if (currentCount >= bucketLimit) {
const errorCode = bucket === "web" ? "WEB_LIMIT_REACHED" : "MAIL_LIMIT_REACHED";
throw createError({
statusCode: 403,
data: {
error: errorCode,
resource: "custom_domains",
bucket,
current: currentCount,
limit: bucketLimit,
},
});
}
}
}
try {
const data = await addUserCustomDomain(user.id, value, "manual", type);
const data = await addUserCustomDomain(
user.id,
value,
"manual",
type,
webAddAsApproved ? "approved" : "active",
);
await awardPoints(user.id, "custom_domain_submitted", { domain: value }).catch(
() => {},
@ -242,6 +281,9 @@ export default defineEventHandler(async (event) => {
});
}
if (webAddAsApproved) {
return { ...data, addedToVip: true };
}
return data;
} catch (err: any) {
const msg =

View File

@ -4,21 +4,29 @@ import { getWebCustomDomains } from "../../db/domains";
const COUNTRY_KEYS = ["DE", "GB", "FR"] as const;
type CountryKey = (typeof COUNTRY_KEYS)[number];
const GLOBAL_LISTS = gamblingDomains as Record<string, string[]> & {
_meta: (typeof gamblingDomains)["_meta"];
};
const GLOBAL_LISTS = gamblingDomains as unknown as Record<string, string[]>;
const MAX_PER_COUNTRY = 50;
// Hybrid-Reservierung: die Top-N kuratierten Gambling-Domains pro Land sind
// FEST garantiert — ein User kann sie nicht mit eigenen Custom-Domains aus
// seinem Layer-2-Zweitschutz verdrängen. Custom-Domains werden daher hart auf
// (50 RESERVED_CURATED) gekappt. Voraussetzung: gambling-domains.json ist
// nach Relevanz sortiert (die ersten RESERVED_CURATED = die wichtigsten).
const RESERVED_CURATED = 20;
const MAX_CUSTOM = MAX_PER_COUNTRY - RESERVED_CURATED; // 30
/**
* GET /api/protection/webcontent-domains
*
* Liefert die VIP-Domain-Liste für den WebKit-webContent-Filter (Layer 2).
* Pro User personalisiert:
* Custom-Web-Domains (aktiv, nicht approved/rejected) + globale Liste
* Pro User personalisiert, Hybrid-Komposition pro Land:
* 1. Custom-Web-Domains (pending zuerst, dann approved) gekappt auf 30
* 2. kuratierte Gambling-Liste füllt den Rest bis 50 auf
* dedupliziert hart auf 50 gekappt (Apple-Limit).
*
* Custom-Domains stehen vorne (User-Priorität).
* Damit sind immer 20 kuratierte Top-Domains im Zweitschutz garantiert,
* egal wie viele Custom-Domains der User angesammelt hat.
* Response-Shape ist identisch mit der statischen Version iOS parst das unverändert.
*
* Lade-Mechanismus: direkter JSON-Import (build-time gebundelt via Nitro-Bundler).
@ -33,7 +41,15 @@ export default defineEventHandler(async (event) => {
// Custom Web-Domains des Users laden — parallel zu allen Country-Listen
const userWebDomains = await getWebCustomDomains(user.id);
const userWebSet = new Set(userWebDomains);
// Custom-Domains hart auf 30 kappen — die ersten 30 sind die höchst-
// priorisierten (getWebCustomDomains liefert pending zuerst, dann approved
// neueste-zuerst). Die restlichen 20 Slots bleiben für die kuratierte Liste.
const cappedCustom = userWebDomains.slice(0, MAX_CUSTOM);
// Dedup-Set NUR über die gekappten Customs — eine kuratierte Domain, die
// einer aus dem 30-Cap GEFLOGENEN Custom-Domain entspricht, soll über die
// kuratierte Auffüllung wieder reinkommen (sie ist ja eine Top-Domain).
const cappedCustomSet = new Set(cappedCustom);
// Pro Country: Custom-Domains vorne, dann globale Auffüllung, dedup, cap 50
const composed: Record<CountryKey, string[]> = {} as Record<
@ -44,12 +60,12 @@ export default defineEventHandler(async (event) => {
for (const country of COUNTRY_KEYS) {
const globalList: string[] = GLOBAL_LISTS[country] ?? [];
// Custom-Domains zuerst (bereits dedupliziert da aus DB)
const merged: string[] = [...userWebDomains];
// Gekappte Custom-Domains zuerst (bereits dedupliziert da aus DB)
const merged: string[] = [...cappedCustom];
// Globale Domains auffüllen — nur wenn noch nicht durch Custom drin
// Kuratierte Domains auffüllen — nur wenn noch nicht durch Custom drin
for (const domain of globalList) {
if (!userWebSet.has(domain)) {
if (!cappedCustomSet.has(domain)) {
merged.push(domain);
}
}

View File

@ -1,7 +1,7 @@
{
"_meta": {
"version": 1,
"updatedAt": "2026-05-21",
"version": 2,
"updatedAt": "2026-05-22",
"maxDomainsPerCountry": 50,
"status": "starter"
},
@ -10,24 +10,26 @@
"tipico.com",
"bwin.de",
"bwin.com",
"lotto.de",
"lotto24.de",
"interwetten.de",
"interwetten.com",
"betano.de",
"bet-at-home.com",
"sportwetten.de",
"merkur-bets.de",
"merkurbets.de",
"happybet.de",
"neobet.de",
"betano.com",
"winamax.de",
"bet-at-home.com",
"betway.de",
"admiralbet.de",
"merkur-bets.de",
"happybet.de",
"neobet.de",
"sportwetten.de",
"oddset.de",
"lottohelden.de",
"lotto.de",
"lotto24.de",
"jackpot.de",
"drueckglueck.de",
"wunderino.com",
"merkurbets.de",
"loewen-play.de",
"merkur24.com",
"casino.de",
@ -35,7 +37,11 @@
"betsson.de",
"leovegas.de",
"lapalingo.com",
"sunmaker.de"
"sunmaker.de",
"pokerstars.de",
"lottoland.com",
"jackpotpiraten.de",
"crazybuzzer.de"
],
"GB": [
"bet365.com",

View File

@ -118,11 +118,12 @@ export async function addUserCustomDomain(
domain: string,
source = "manual",
type: CustomDomainType = "web",
status = "active",
) {
const db = usePrisma();
return db.userCustomDomain.create({
data: { userId, domain, source, type },
select: { id: true, domain: true, type: true },
data: { userId, domain, source, type, status },
select: { id: true, domain: true, type: true, status: true },
});
}