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:
parent
e946fbe443
commit
fe156a5f58
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
66
apps/rebreak-native/hooks/useWebContentDomains.ts
Normal file
66
apps/rebreak-native/hooks/useWebContentDomains.ts
Normal 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 };
|
||||
}
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user