From 8a6ab6fe64fb74fbe33eb827fa9e77ee3faf645b Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sat, 16 May 2026 02:54:38 +0200 Subject: [PATCH] feat(native/blocker): unified slot bar + single + button + auto-detect sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single shared affordance for adding either a website-domain or a mail- sender-domain. The per-section add buttons (one inside "Eigene Domains" and one inside "Eigene Mails") are gone — replaced by a CustomFilter- Overview card above both sections with: - title "Eigene Filter" and a "X von 20" counter (free/pro: 10, legend: 20 — sum of the two per-type buckets) - a 2-colour progress pill: brandOrange for the web slice, success-green for the mail slice on top of the surface-elevated rest - a 48×48 rounded-full TouchableOpacity on the right (brandOrange, ionicons add 24px, white) that opens the AddDomainSheet directly AddDomainSheet was rewritten one more time: the Seite / E-Mail type picker is gone. The user types one thing — domain or full address — and a live preview shows which one we detected (Domain-Filter for a bare host, Mail-Filter for input that contains "@", stripping to the domain after the last @). The shape is also what we send: the body is { pattern } with no kind field. The backend (commit a2680f6) does the authoritative auto-detect and sends back the resolved type with the created row, so the frontend never has to guess in two places. useCustomDomains.addDomain now treats kind as optional. When omitted, the request body just carries pattern — when present it's still sent through verbatim so any caller that wants to force a category still can. DomainSection no longer renders a per-section add button when its onAdd prop is undefined — domains and mails sections in blocker.tsx both omit onAdd now. The mails section stays default-collapsed. i18n: new keys custom_filter_overview_title / count + preview_web / preview_mail / preview_invalid; tabs_web / tabs_mail removed since the TypePicker is gone. type_web / type_mail kept in the locales as inactive entries in case the type-picker comes back in a future direct-add flow. --- apps/rebreak-native/app/(app)/blocker.tsx | 181 +++++++--- .../components/blocker/AddDomainSheet.tsx | 322 +++++++----------- apps/rebreak-native/hooks/useCustomDomains.ts | 15 +- apps/rebreak-native/locales/de.json | 16 +- apps/rebreak-native/locales/en.json | 16 +- apps/rebreak-native/locales/fr.json | 16 +- 6 files changed, 293 insertions(+), 273 deletions(-) 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}",