diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx index b69aee8..69c4536 100644 --- a/apps/rebreak-native/app/(app)/blocker.tsx +++ b/apps/rebreak-native/app/(app)/blocker.tsx @@ -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) */} setAddSheetOpen(true)} - onRemoveDomain={handleRemoveWebDomain} + onSubmitDomain={submitDomain} colors={colors} /> {/* Sektion 2: VIP-Liste (Zweitschutz, collapsible) */} 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(); diff --git a/apps/rebreak-native/components/blocker/AddDomainSheet.tsx b/apps/rebreak-native/components/blocker/AddDomainSheet.tsx index 77dcb9b..2928228 100644 --- a/apps/rebreak-native/components/blocker/AddDomainSheet.tsx +++ b/apps/rebreak-native/components/blocker/AddDomainSheet.tsx @@ -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; }; function detectKind(input: string): 'web' | 'mail' | null { @@ -49,11 +54,15 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) { const [error, setError] = useState(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(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 ( { 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) { )} - {/* Help text */} - - - - {t('blocker.add_sheet_help')} - - - - {/* Preview card */} - - - {/* Override toggle — User kann Auto-Detect korrigieren falls falsch erkannt */} - {detected !== null && ( - 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. */ + - + - {t('blocker.kind_override_label')} + {t('blocker.add_sheet_in_global_not_vip', { domain: vipPrompt ?? '' })} - - )} - - {/* Warning card */} - - - - {warningText} - - - - {/* Confirm checkbox */} - setConfirmPermanent((v) => !v)} - activeOpacity={0.7} - style={{ - flexDirection: 'row', - alignItems: 'flex-start', - gap: 10, - paddingVertical: 4, - }} - > - - {confirmPermanent && } - - {t('blocker.add_sheet_confirm_permanent')} - - + ) : ( + <> + {/* Help text */} + + + + {t('blocker.add_sheet_help')} + + + + {/* Preview card */} + + + {/* Override toggle — User kann Auto-Detect korrigieren falls falsch erkannt */} + {detected !== null && ( + 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, + }} + > + + + {t('blocker.kind_override_label')} + + + )} + + {/* Warning card */} + + + + {warningText} + + + + {/* Confirm checkbox */} + setConfirmPermanent((v) => !v)} + activeOpacity={0.7} + style={{ + flexDirection: 'row', + alignItems: 'flex-start', + gap: 10, + paddingVertical: 4, + }} + > + + {confirmPermanent && } + + + {t('blocker.add_sheet_confirm_permanent')} + + + + )} {/* Buttons */} @@ -330,14 +403,14 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) { ) : ( - {t('blocker.add_sheet_cta')} + {inVipMode + ? t('blocker.add_sheet_add_to_vip_cta') + : t('blocker.add_sheet_cta')} )} diff --git a/apps/rebreak-native/components/blocker/RemoveDomainSheet.tsx b/apps/rebreak-native/components/blocker/RemoveDomainSheet.tsx deleted file mode 100644 index 6422e27..0000000 --- a/apps/rebreak-native/components/blocker/RemoveDomainSheet.tsx +++ /dev/null @@ -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; -}; - -/** - * 3-Click Friction-Gate vor dem Entfernen einer eigenen Web-Domain. - * - * Selbes UX-Muster wie DeactivationExplainerSheet: - * Click 1: User tippt Papierkorb-Icon → Sheet öffnet sich (dieser Component) - * Click 2: User liest Kontext → primäre Aktion = "Behalten" (Deflect) - * sekundäre Aktion = "Trotzdem entfernen" (klein, destructive) - * Click 3: ActionSheet / Alert zur finalen Bestätigung → removeDomain() - * - * Kein Cooldown wird gestartet — nur der Domain-Delete-Endpoint. - * Die Verzögerung entsteht durch das bewusste 3-Schritt-UX, nicht durch - * eine zeitliche Sperre (anders als beim Schutz-Deaktivieren). - */ -export function RemoveDomainSheet({ visible, domain, onClose, onConfirmRemove }: Props) { - const { t } = useTranslation(); - const colors = useColors(); - const insets = useSafeAreaInsets(); - const [submitting, setSubmitting] = useState(false); - - function showFinalConfirm() { - const title = t('blocker.remove_domain_actionsheet_title'); - const message = t('blocker.remove_domain_actionsheet_message', { domain }); - const cancelLabel = t('common.cancel'); - const confirmLabel = t('blocker.remove_domain_confirm_cta'); - - if (Platform.OS === 'ios') { - ActionSheetIOS.showActionSheetWithOptions( - { - title, - message, - options: [cancelLabel, confirmLabel], - destructiveButtonIndex: 1, - cancelButtonIndex: 0, - }, - async (idx) => { - if (idx === 1) await runRemove(); - }, - ); - } else { - Alert.alert(title, message, [ - { text: cancelLabel, style: 'cancel' }, - { text: confirmLabel, style: 'destructive', onPress: runRemove }, - ]); - } - } - - async function runRemove() { - setSubmitting(true); - try { - await onConfirmRemove(); - onClose(); - } catch (e: any) { - Alert.alert(t('common.error'), e?.message ?? t('blocker.remove_domain_failed')); - } finally { - setSubmitting(false); - } - } - - return ( - - - - {t('blocker.remove_domain_title')} - - - - - - {domain} - - - - - {t('blocker.remove_domain_intro')} - - - - - - - - - - - - - - {t('blocker.remove_domain_keep_cta')} - - - - - - - {submitting ? t('blocker.remove_domain_removing') : t('blocker.remove_domain_remove_anyway')} - - - - - ); -} - -function BulletRow({ - icon, - title, - text, -}: { - icon: React.ComponentProps['name']; - title: string; - text: string; -}) { - const colors = useColors(); - return ( - - - - - - - {title} - - - {text} - - - - ); -} diff --git a/apps/rebreak-native/components/blocker/VipDomainList.tsx b/apps/rebreak-native/components/blocker/VipDomainList.tsx index 4fc7664..df9a772 100644 --- a/apps/rebreak-native/components/blocker/VipDomainList.tsx +++ b/apps/rebreak-native/components/blocker/VipDomainList.tsx @@ -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; + 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 ? ( ) : ( - + )} @@ -184,15 +191,17 @@ function MyFiltersEmptyState({ onAddPress, colors }: { onAddPress: () => void; c function FilterTilesGrid({ domains, - onRemoveDomain, + tier, + onSubmit, }: { domains: CustomDomain[]; - onRemoveDomain: (id: string) => Promise; + tier: Tier; + onSubmit: (id: string) => Promise<{ ok: boolean }>; }) { return ( {domains.map((d) => ( - + ))} ); @@ -200,19 +209,31 @@ function FilterTilesGrid({ function FilterTile({ domain, - onRemove, + tier, + onSubmit, }: { domain: CustomDomain; - onRemove: (id: string) => Promise; + 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({ - {/* Remove button */} - setRemoveSheetOpen(true)} - disabled={removing} - activeOpacity={0.65} - style={{ - height: 26, - borderRadius: 6, - borderWidth: 1, - borderColor: colors.border, - alignItems: 'center', - justifyContent: 'center', - }} - > - {removing ? ( - - ) : ( - - )} - + {/* Bottom slot — Freigabe / Erneut / in Prüfung. Immer 26px hoch, + damit alle Kacheln gleich hoch bleiben. */} + + {domain.status === 'submitted' ? ( + + + {isLegend ? t('blocker.domain_btn_rebreak_prueft') : t('blocker.domain_btn_in_abstimmung')} + + + ) : canSubmit ? ( + setConfirmVisible(true)} + disabled={submitting} + activeOpacity={0.65} + style={{ + flex: 1, + borderRadius: 6, + borderWidth: 1, + borderColor: btnColor, + alignItems: 'center', + justifyContent: 'center', + }} + > + {submitting ? ( + + ) : ( + + {isResubmit ? t('blocker.domain_btn_erneut') : t('blocker.domain_btn_freigeben')} + + )} + + ) : null} + - setRemoveSheetOpen(false)} - onConfirmRemove={handleConfirmRemove} + setConfirmVisible(false)} + /> + + 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(); + 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 ( - {webDomains.length > 0 && ( - - {webDomains.map((d) => ( - - ))} + {loading && vipList === null ? ( + + + ) : ( + <> + + {t('blocker.vip_layer2_count', { count: list.length })} + + {list.length > 0 && ( + + {list.map((d) => ( + + ))} + + )} + )} - - - - - {t('blocker.vip_layer2_global_hint', { count: globalBlocklistCount })} - - )} ); } -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 ( - - - + {isPending && ( + + )} + - {stripped} - + + + {stripped} + + ); } diff --git a/apps/rebreak-native/hooks/useCustomDomains.ts b/apps/rebreak-native/hooks/useCustomDomains.ts index c05cc11..d182642 100644 --- a/apps/rebreak-native/hooks/useCustomDomains.ts +++ b/apps/rebreak-native/hooks/useCustomDomains.ts @@ -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; - addDomain: (pattern: string, kind?: 'web' | 'mail') => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>; + addDomain: ( + pattern: string, + kind?: 'web' | 'mail', + opts?: { addToVip?: boolean }, + ) => Promise; 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 => { 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 = { pattern }; + const body: Record = { 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('/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( diff --git a/apps/rebreak-native/hooks/useWebContentDomains.ts b/apps/rebreak-native/hooks/useWebContentDomains.ts new file mode 100644 index 0000000..8f506bd --- /dev/null +++ b/apps/rebreak-native/hooks/useWebContentDomains.ts @@ -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; + +export function useWebContentDomains() { + const [country] = useState(resolveVipCountry); + const [domains, setDomains] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const refetch = useCallback(async () => { + try { + const res = await apiFetch( + '/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 }; +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 303d91c..c195354 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -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.", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 7e49b67..1a01916 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -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.", diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/gambling-domains.json b/apps/rebreak-native/modules/rebreak-protection/ios/gambling-domains.json index 39fc94c..53e7600 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/gambling-domains.json +++ b/apps/rebreak-native/modules/rebreak-protection/ios/gambling-domains.json @@ -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", diff --git a/backend/server/api/custom-domains/index.post.ts b/backend/server/api/custom-domains/index.post.ts index e5ad8cd..d0d0852 100644 --- a/backend/server/api/custom-domains/index.post.ts +++ b/backend/server/api/custom-domains/index.post.ts @@ -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; +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 = diff --git a/backend/server/api/protection/webcontent-domains.get.ts b/backend/server/api/protection/webcontent-domains.get.ts index 593a71e..10e94e4 100644 --- a/backend/server/api/protection/webcontent-domains.get.ts +++ b/backend/server/api/protection/webcontent-domains.get.ts @@ -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 & { - _meta: (typeof gamblingDomains)["_meta"]; -}; +const GLOBAL_LISTS = gamblingDomains as unknown as Record; 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 = {} 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); } } diff --git a/backend/server/data/gambling-domains.json b/backend/server/data/gambling-domains.json index d85c403..d8c83d5 100644 --- a/backend/server/data/gambling-domains.json +++ b/backend/server/data/gambling-domains.json @@ -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", diff --git a/backend/server/db/domains.ts b/backend/server/db/domains.ts index 646d343..16309ec 100644 --- a/backend/server/db/domains.ts +++ b/backend/server/db/domains.ts @@ -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 }, }); }