diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx index ef80161..e2eb8fe 100644 --- a/apps/rebreak-native/app/(app)/blocker.tsx +++ b/apps/rebreak-native/app/(app)/blocker.tsx @@ -17,7 +17,7 @@ import { useCustomDomains } from '../../hooks/useCustomDomains'; import { useBlocklistSync } from '../../hooks/useBlocklistSync'; import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime'; import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection'; -import { useColors } from '../../lib/theme'; +import { useColors, type ColorScheme } from '../../lib/theme'; export default function BlockerScreen() { const router = useRouter(); @@ -62,10 +62,7 @@ export default function BlockerScreen() { useDomainSubmissionRealtime(onDomainChange, true); const [mailOpen, setMailOpen] = useState(false); - - // AddSheet state: tracks which section opened it const [addSheetOpen, setAddSheetOpen] = useState(false); - const [addSheetKind, setAddSheetKind] = useState<'web' | 'mail'>('web'); const [detailsOpen, setDetailsOpen] = useState(false); const [explainerOpen, setExplainerOpen] = useState(false); @@ -196,11 +193,6 @@ export default function BlockerScreen() { ); }, [state?.phase, t]); - function openAddSheet(kind: 'web' | 'mail') { - setAddSheetKind(kind); - setAddSheetOpen(true); - } - // ─── Render ────────────────────────────────────────────────────────── return ( @@ -362,12 +354,22 @@ export default function BlockerScreen() { )} + {/* Custom-Filter-Slot-Übersicht */} + setAddSheetOpen(true)} + colors={colors} + t={t} + /> + {/* Section 1: Eigene Domains */} openAddSheet('web')} atLimit={countsByType.web >= limits.web} > setMailOpen((v) => !v)} - onAdd={() => openAddSheet('mail')} atLimit={countsByType.mail >= limits.mail} > { setAddSheetOpen(false); refreshDomains(); }} - onAdd={async (pattern, kind) => { - const result = await addDomain(pattern, kind); + onAdd={async (pattern) => { + const result = await addDomain(pattern); if (result.ok) { const sync = await syncBlocklist(); if (sync.ok) refresh(); @@ -439,6 +439,124 @@ export default function BlockerScreen() { ); } +// ─── CustomFilterOverview ───────────────────────────────────────────────────── + +function CustomFilterOverview({ + webCount, + mailCount, + webLimit, + mailLimit, + onAddPress, + colors, + t, +}: { + webCount: number; + mailCount: number; + webLimit: number; + mailLimit: number; + onAddPress: () => void; + colors: ColorScheme; + t: (key: string, opts?: Record) => string; +}) { + const total = webCount + mailCount; + const max = webLimit + mailLimit; + const webFillAnim = useRef(new Animated.Value(0)).current; + const mailFillAnim = useRef(new Animated.Value(0)).current; + + const webRatio = max > 0 ? Math.min(webCount / max, 1) : 0; + const mailRatio = max > 0 ? Math.min(mailCount / max, 1) : 0; + + useEffect(() => { + Animated.parallel([ + Animated.timing(webFillAnim, { toValue: webRatio, duration: 380, useNativeDriver: false }), + Animated.timing(mailFillAnim, { toValue: mailRatio, duration: 380, useNativeDriver: false }), + ]).start(); + }, [webRatio, mailRatio]); + + return ( + + {/* Left: title + counter + bar */} + + + {t('blocker.custom_filter_overview_title')} + + + {t('blocker.custom_filter_overview_count', { count: total, max })} + + + {/* Split progress bar */} + + + + + + {/* Legend dots */} + + + + + {webCount} Web + + + + + + {mailCount} Mail + + + + + + {/* Right: add button */} + + + + + ); +} + // ─── DomainSection ──────────────────────────────────────────────────────────── function DomainSection({ @@ -448,7 +566,6 @@ function DomainSection({ collapsible = false, open = true, onToggle, - onAdd, atLimit, children, }: { @@ -458,14 +575,12 @@ function DomainSection({ collapsible?: boolean; open?: boolean; onToggle?: () => void; - onAdd: () => void; atLimit: boolean; children: React.ReactNode; }) { const { t } = useTranslation(); const colors = useColors(); - // Animated progress bar const fillAnim = useRef(new Animated.Value(0)).current; const ratio = max > 0 ? Math.min(count / max, 1) : 0; @@ -553,38 +668,6 @@ function DomainSection({ /> - {/* Add-Button */} - - - - - {t('blocker.add_domain')} - - - - {/* Grid */} {children} diff --git a/apps/rebreak-native/components/blocker/AddDomainSheet.tsx b/apps/rebreak-native/components/blocker/AddDomainSheet.tsx index ec3489b..2867913 100644 --- a/apps/rebreak-native/components/blocker/AddDomainSheet.tsx +++ b/apps/rebreak-native/components/blocker/AddDomainSheet.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { ActivityIndicator, Image, @@ -18,41 +18,39 @@ import { import { useColors, type ColorScheme } from '../../lib/theme'; import { FormSheet } from '../FormSheet'; -type InputKind = 'web' | 'mail'; - type Props = { visible: boolean; tier: Tier; - initialType?: InputKind; onClose: () => void; - onAdd: (pattern: string, kind: InputKind) => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>; + onAdd: (pattern: string) => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>; }; -export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: Props) { +function detectKind(input: string): 'web' | 'mail' | null { + const raw = input.trim(); + if (!raw) return null; + if (raw.includes('@')) return 'mail'; + if (raw.includes('.')) return 'web'; + return null; +} + +function mailDomain(input: string): string { + const raw = input.trim(); + const atIdx = raw.lastIndexOf('@'); + if (atIdx === -1) return raw.toLowerCase(); + return raw.slice(atIdx + 1).trim().toLowerCase(); +} + +export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) { const { t } = useTranslation(); const colors = useColors(); - const [kind, setKind] = useState(initialType ?? 'web'); const [input, setInput] = useState(''); const [confirmPermanent, setConfirmPermanent] = useState(false); const [adding, setAdding] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - if (visible) setKind(initialType ?? 'web'); - }, [visible, initialType]); - + const kind = detectKind(input); const normalizedWeb = kind === 'web' ? normalizeDomain(input) : ''; - - // For mail input: if the user typed a full address (local@domain.tld), strip - // the local-part and keep only the domain. A bare domain without "@" stays as-is. - const mailPattern = (() => { - if (kind !== 'mail') return ''; - const raw = input.trim(); - if (!raw) return ''; - const atIdx = raw.lastIndexOf('@'); - if (atIdx === -1) return raw.toLowerCase(); - return raw.slice(atIdx + 1).trim().toLowerCase(); - })(); + const normalizedMail = kind === 'mail' ? mailDomain(input) : ''; function close() { setInput(''); @@ -61,31 +59,25 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P onClose(); } - function handleKindChange(next: InputKind) { - if (next === kind) return; - setKind(next); - setInput(''); - setError(null); - } - function isInputValid(): boolean { if (kind === 'web') return isValidDomain(input); - return input.trim().length > 0; + if (kind === 'mail') return normalizedMail.length > 0; + return false; } async function handleAdd() { if (!isInputValid() || !confirmPermanent || adding) return; setAdding(true); setError(null); - const pattern = kind === 'web' ? normalizeDomain(input) : mailPattern; - const result = await onAdd(pattern, kind); + const pattern = kind === 'web' ? normalizeDomain(input) : normalizedMail; + const result = await onAdd(pattern); setAdding(false); if (result.ok) { close(); return; } if (result.alreadyGlobal) { - setError(t('blocker.add_sheet_already_global', { domain: normalizedWeb || input.trim() })); + setError(t('blocker.add_sheet_already_global', { domain: pattern })); } else if (result.error?.includes('WEB_LIMIT_REACHED')) { setError(t('blocker.error_web_limit_reached')); } else if (result.error?.includes('MAIL_LIMIT_REACHED')) { @@ -100,18 +92,6 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P ? t('blocker.add_sheet_warning_free') : t('blocker.add_sheet_warning_pro'); - const inputLabel = kind === 'web' - ? t('blocker.add_web_label') - : t('blocker.add_mail_label'); - - const inputPlaceholder = kind === 'web' - ? t('blocker.add_web_placeholder') - : t('blocker.add_mail_placeholder'); - - const helpText = kind === 'web' - ? t('blocker.add_web_help') - : t('blocker.add_mail_help'); - const canSubmit = isInputValid() && confirmPermanent && !adding; return ( @@ -127,20 +107,17 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P showsVerticalScrollIndicator={false} contentContainerStyle={{ padding: 16, gap: 12 }} > - {/* 1. Type-Picker Pill */} - - - {/* 2. Input-Field */} + {/* Input field */} - {inputLabel} + {t('blocker.add_sheet_label')} { setInput(v); setError(null); }} - placeholder={inputPlaceholder} + placeholder={t('blocker.add_sheet_placeholder')} placeholderTextColor={colors.textMuted} - keyboardType={kind === 'web' ? 'url' : 'email-address'} + keyboardType="email-address" autoCapitalize="none" autoCorrect={false} style={{ @@ -161,7 +138,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P )} - {/* 3. Help-Text */} + {/* Help text */} - {helpText} + {t('blocker.add_sheet_help')} - {/* 4. Preview-Card */} - {kind === 'web' ? ( - - - - {normalizedWeb || inputPlaceholder} - - - ) : ( - - - - - - {mailPattern || inputPlaceholder} - - - )} + {/* Preview card */} + - {/* 5. Warning-Card */} + {/* Warning card */} - {/* 6. Confirm-Checkbox */} + {/* Confirm checkbox */} setConfirmPermanent((v) => !v)} activeOpacity={0.7} @@ -320,13 +242,9 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P - {/* 7. Buttons */} + {/* Buttons */} - + void; + kind: 'web' | 'mail' | null; + normalizedWeb: string; + normalizedMail: string; + placeholder: string; colors: ColorScheme; + t: (key: string, opts?: Record) => string; }) { - const { t } = useTranslation(); + if (kind === 'web') { + return ( + + + + + {t('blocker.preview_web', { value: normalizedWeb || '…' })} + + + + + ); + } + + if (kind === 'mail') { + return ( + + + + + + + {t('blocker.preview_mail', { value: normalizedMail || '…' })} + + + + + ); + } return ( - onChange('web')} - colors={colors} - /> - onChange('mail')} - colors={colors} - /> + + + {t('blocker.preview_invalid')} + ); } - -function TypePill({ - icon, - label, - active, - onPress, - colors, -}: { - icon: 'globe-outline' | 'mail-outline'; - label: string; - active: boolean; - onPress: () => void; - colors: ColorScheme; -}) { - return ( - - - - {label} - - - ); -} diff --git a/apps/rebreak-native/hooks/useCustomDomains.ts b/apps/rebreak-native/hooks/useCustomDomains.ts index 21d00e4..8e7b2b5 100644 --- a/apps/rebreak-native/hooks/useCustomDomains.ts +++ b/apps/rebreak-native/hooks/useCustomDomains.ts @@ -60,7 +60,7 @@ export type UseCustomDomainsReturn = { loading: boolean; error: string | null; refresh: () => Promise; - addDomain: (domain: string, kind?: 'web' | 'mail') => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>; + addDomain: (pattern: string, kind?: 'web' | 'mail') => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>; 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,16 +141,19 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn { }, [fetchDomains]); const addDomain = useCallback( - async (input: string, kind: 'web' | 'mail' = 'web') => { - if (kind === 'web' && !isValidDomain(input)) return { ok: false, error: 'invalid_domain' }; - if (kind === 'mail' && !input.trim()) return { ok: false, error: 'invalid_pattern' }; + async (input: string, kind?: 'web' | 'mail') => { + 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' }; const tier = deriveTier(plan, domains); if (tier.atLimit) return { ok: false, error: 'limit_reached' }; - const pattern = kind === 'web' ? normalizeDomain(input) : input.trim(); + const pattern = resolvedKind === 'web' ? normalizeDomain(input) : input.trim(); + const body: Record = { pattern }; + if (kind !== undefined) body.kind = kind; try { const res = await apiFetch('/api/custom-domains', { method: 'POST', - body: { pattern, kind }, + body, }); if (res?.alreadyGlobal) { return { ok: false, alreadyGlobal: true }; diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index ce2c6f0..6274109 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -185,10 +185,16 @@ "status_approved": "Genehmigt", "status_rejected": "Abgelehnt", "status_pending": "Ausstehend", - "add_sheet_title": "Domain blockieren", - "add_sheet_label": "Domain", - "add_sheet_placeholder": "z.B. bet365.com", - "add_sheet_invalid": "Bitte gültige Domain eingeben (z.B. example.com)", + "add_sheet_title": "Filter hinzufügen", + "add_sheet_label": "Domain oder E-Mail-Adresse", + "add_sheet_placeholder": "z.B. casino.com oder info@casino.com", + "add_sheet_invalid": "Bitte gültige Domain oder E-Mail-Adresse eingeben", + "add_sheet_help": "Wir erkennen automatisch ob es eine Webseite oder ein Mail-Absender ist.", + "preview_web": "Domain-Filter: %{value}", + "preview_mail": "Mail-Filter: %{value}", + "preview_invalid": "Ungültiges Format", + "custom_filter_overview_title": "Eigene Filter", + "custom_filter_overview_count": "%{count} von %{max}", "add_sheet_warning_free": "Diese Domain bleibt dauerhaft auf deiner Liste — du kannst sie später nicht entfernen.", "add_sheet_warning_pro": "Diese Domain ist permanent. Du kannst sie zur globalen Blocklist freigeben — dann wird der Slot frei und sie schützt alle ReBreak-User.", "add_sheet_confirm_permanent": "Ich verstehe dass diese Domain permanent ist.", @@ -327,8 +333,6 @@ "add_mail_help": "E-Mail-Adresse oder Mail-Domain. Wir blockieren alle Mails von diesem Absender.", "add_mail_invalid": "Bitte ein Muster eingeben.", "add_sheet_cta": "Hinzufügen", - "tabs_web": "Seiten", - "tabs_mail": "Mails", "section_domains": "Eigene Domains", "section_mails": "Eigene Mails", "count_label": "%{count}/%{max}", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 0bb54eb..e3ddb2a 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -185,10 +185,16 @@ "status_approved": "Approved", "status_rejected": "Rejected", "status_pending": "Pending", - "add_sheet_title": "Block domain", - "add_sheet_label": "Domain", - "add_sheet_placeholder": "e.g. bet365.com", - "add_sheet_invalid": "Please enter a valid domain (e.g. example.com)", + "add_sheet_title": "Add filter", + "add_sheet_label": "Domain or email address", + "add_sheet_placeholder": "e.g. casino.com or info@casino.com", + "add_sheet_invalid": "Please enter a valid domain or email address", + "add_sheet_help": "We automatically detect whether it's a website or an email sender.", + "preview_web": "Domain filter: %{value}", + "preview_mail": "Email filter: %{value}", + "preview_invalid": "Invalid format", + "custom_filter_overview_title": "Your Filters", + "custom_filter_overview_count": "%{count} of %{max}", "add_sheet_warning_free": "This domain stays on your list permanently — you cannot remove it later.", "add_sheet_warning_pro": "This domain is permanent. You can release it to the global blocklist — the slot becomes free again and it will protect every ReBreak user.", "add_sheet_confirm_permanent": "I understand this domain is permanent.", @@ -327,8 +333,6 @@ "add_mail_help": "Email address or mail domain. We block all emails from this sender.", "add_mail_invalid": "Please enter a pattern.", "add_sheet_cta": "Add", - "tabs_web": "Websites", - "tabs_mail": "Emails", "section_domains": "Your Domains", "section_mails": "Your Email Filters", "count_label": "%{count}/%{max}", diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json index 02a03b4..4ffea53 100644 --- a/apps/rebreak-native/locales/fr.json +++ b/apps/rebreak-native/locales/fr.json @@ -185,10 +185,16 @@ "status_approved": "Approuvé", "status_rejected": "Refusé", "status_pending": "En attente", - "add_sheet_title": "Bloquer un domaine", - "add_sheet_label": "Domaine", - "add_sheet_placeholder": "ex. bet365.com", - "add_sheet_invalid": "Veuillez saisir un domaine valide (ex. example.com)", + "add_sheet_title": "Ajouter un filtre", + "add_sheet_label": "Domaine ou adresse e-mail", + "add_sheet_placeholder": "ex. casino.com ou info@casino.com", + "add_sheet_invalid": "Veuillez saisir un domaine ou une adresse e-mail valide", + "add_sheet_help": "Nous détectons automatiquement s'il s'agit d'un site web ou d'un expéditeur d'e-mails.", + "preview_web": "Filtre domaine : %{value}", + "preview_mail": "Filtre e-mail : %{value}", + "preview_invalid": "Format invalide", + "custom_filter_overview_title": "Mes filtres", + "custom_filter_overview_count": "%{count} sur %{max}", "add_sheet_warning_free": "Ce domaine reste définitivement dans votre liste — vous ne pourrez pas le supprimer plus tard.", "add_sheet_warning_pro": "Ce domaine est permanent. Vous pouvez le proposer à la liste de blocage globale — le slot sera libéré et protégera tous les utilisateurs ReBreak.", "add_sheet_confirm_permanent": "Je comprends que ce domaine est permanent.", @@ -327,8 +333,6 @@ "add_mail_help": "Adresse e-mail ou domaine mail. Nous bloquons tous les mails de cet expéditeur.", "add_mail_invalid": "Veuillez saisir un modèle.", "add_sheet_cta": "Ajouter", - "tabs_web": "Sites", - "tabs_mail": "E-mails", "section_domains": "Mes domaines", "section_mails": "Mes filtres mail", "count_label": "%{count}/%{max}",