feat(native/blocker): unified slot bar + single + button + auto-detect sheet

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.
This commit is contained in:
chahinebrini 2026-05-16 02:54:38 +02:00
parent a2680f6e19
commit 8a6ab6fe64
6 changed files with 293 additions and 273 deletions

View File

@ -17,7 +17,7 @@ import { useCustomDomains } from '../../hooks/useCustomDomains';
import { useBlocklistSync } from '../../hooks/useBlocklistSync'; import { useBlocklistSync } from '../../hooks/useBlocklistSync';
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime'; import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection'; import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection';
import { useColors } from '../../lib/theme'; import { useColors, type ColorScheme } from '../../lib/theme';
export default function BlockerScreen() { export default function BlockerScreen() {
const router = useRouter(); const router = useRouter();
@ -62,10 +62,7 @@ export default function BlockerScreen() {
useDomainSubmissionRealtime(onDomainChange, true); useDomainSubmissionRealtime(onDomainChange, true);
const [mailOpen, setMailOpen] = useState(false); const [mailOpen, setMailOpen] = useState(false);
// AddSheet state: tracks which section opened it
const [addSheetOpen, setAddSheetOpen] = useState(false); const [addSheetOpen, setAddSheetOpen] = useState(false);
const [addSheetKind, setAddSheetKind] = useState<'web' | 'mail'>('web');
const [detailsOpen, setDetailsOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false);
const [explainerOpen, setExplainerOpen] = useState(false); const [explainerOpen, setExplainerOpen] = useState(false);
@ -196,11 +193,6 @@ export default function BlockerScreen() {
); );
}, [state?.phase, t]); }, [state?.phase, t]);
function openAddSheet(kind: 'web' | 'mail') {
setAddSheetKind(kind);
setAddSheetOpen(true);
}
// ─── Render ────────────────────────────────────────────────────────── // ─── Render ──────────────────────────────────────────────────────────
return ( return (
@ -362,12 +354,22 @@ export default function BlockerScreen() {
</View> </View>
)} )}
{/* Custom-Filter-Slot-Übersicht */}
<CustomFilterOverview
webCount={countsByType.web}
mailCount={countsByType.mail}
webLimit={limits.web}
mailLimit={limits.mail}
onAddPress={() => setAddSheetOpen(true)}
colors={colors}
t={t}
/>
{/* Section 1: Eigene Domains */} {/* Section 1: Eigene Domains */}
<DomainSection <DomainSection
title={t('blocker.section_domains')} title={t('blocker.section_domains')}
count={countsByType.web} count={countsByType.web}
max={limits.web} max={limits.web}
onAdd={() => openAddSheet('web')}
atLimit={countsByType.web >= limits.web} atLimit={countsByType.web >= limits.web}
> >
<DomainGrid <DomainGrid
@ -387,7 +389,6 @@ export default function BlockerScreen() {
collapsible collapsible
open={mailOpen} open={mailOpen}
onToggle={() => setMailOpen((v) => !v)} onToggle={() => setMailOpen((v) => !v)}
onAdd={() => openAddSheet('mail')}
atLimit={countsByType.mail >= limits.mail} atLimit={countsByType.mail >= limits.mail}
> >
<DomainGrid <DomainGrid
@ -404,13 +405,12 @@ export default function BlockerScreen() {
<AddDomainSheet <AddDomainSheet
visible={addSheetOpen} visible={addSheetOpen}
tier={tier} tier={tier}
initialType={addSheetKind}
onClose={() => { onClose={() => {
setAddSheetOpen(false); setAddSheetOpen(false);
refreshDomains(); refreshDomains();
}} }}
onAdd={async (pattern, kind) => { onAdd={async (pattern) => {
const result = await addDomain(pattern, kind); const result = await addDomain(pattern);
if (result.ok) { if (result.ok) {
const sync = await syncBlocklist(); const sync = await syncBlocklist();
if (sync.ok) refresh(); 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, unknown>) => 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 (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
padding: 14,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
}}
>
{/* Left: title + counter + bar */}
<View style={{ flex: 1, gap: 6 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.custom_filter_overview_title')}
</Text>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('blocker.custom_filter_overview_count', { count: total, max })}
</Text>
{/* Split progress bar */}
<View
style={{
height: 7,
borderRadius: 999,
backgroundColor: colors.surfaceElevated,
flexDirection: 'row',
overflow: 'hidden',
}}
>
<Animated.View
style={{
height: '100%',
backgroundColor: colors.brandOrange,
width: webFillAnim.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] }),
}}
/>
<Animated.View
style={{
height: '100%',
backgroundColor: colors.success,
width: mailFillAnim.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] }),
}}
/>
</View>
{/* Legend dots */}
<View style={{ flexDirection: 'row', gap: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: colors.brandOrange }} />
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{webCount} Web
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: colors.success }} />
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{mailCount} Mail
</Text>
</View>
</View>
</View>
{/* Right: add button */}
<TouchableOpacity
onPress={onAddPress}
activeOpacity={0.85}
style={{
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.brandOrange,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="add" size={26} color="#fff" />
</TouchableOpacity>
</View>
);
}
// ─── DomainSection ──────────────────────────────────────────────────────────── // ─── DomainSection ────────────────────────────────────────────────────────────
function DomainSection({ function DomainSection({
@ -448,7 +566,6 @@ function DomainSection({
collapsible = false, collapsible = false,
open = true, open = true,
onToggle, onToggle,
onAdd,
atLimit, atLimit,
children, children,
}: { }: {
@ -458,14 +575,12 @@ function DomainSection({
collapsible?: boolean; collapsible?: boolean;
open?: boolean; open?: boolean;
onToggle?: () => void; onToggle?: () => void;
onAdd: () => void;
atLimit: boolean; atLimit: boolean;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const colors = useColors(); const colors = useColors();
// Animated progress bar
const fillAnim = useRef(new Animated.Value(0)).current; const fillAnim = useRef(new Animated.Value(0)).current;
const ratio = max > 0 ? Math.min(count / max, 1) : 0; const ratio = max > 0 ? Math.min(count / max, 1) : 0;
@ -553,38 +668,6 @@ function DomainSection({
/> />
</View> </View>
{/* Add-Button */}
<TouchableOpacity
onPress={atLimit ? undefined : onAdd}
disabled={atLimit}
activeOpacity={0.75}
style={{ alignSelf: 'flex-start' }}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 10,
backgroundColor: atLimit ? colors.surfaceElevated : '#007AFF',
opacity: atLimit ? 0.5 : 1,
}}
>
<Ionicons name="add" size={16} color={atLimit ? colors.textMuted : '#fff'} />
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: atLimit ? colors.textMuted : '#fff',
}}
>
{t('blocker.add_domain')}
</Text>
</View>
</TouchableOpacity>
{/* Grid */} {/* Grid */}
{children} {children}
</View> </View>

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Image, Image,
@ -18,41 +18,39 @@ import {
import { useColors, type ColorScheme } from '../../lib/theme'; import { useColors, type ColorScheme } from '../../lib/theme';
import { FormSheet } from '../FormSheet'; import { FormSheet } from '../FormSheet';
type InputKind = 'web' | 'mail';
type Props = { type Props = {
visible: boolean; visible: boolean;
tier: Tier; tier: Tier;
initialType?: InputKind;
onClose: () => void; 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 { t } = useTranslation();
const colors = useColors(); const colors = useColors();
const [kind, setKind] = useState<InputKind>(initialType ?? 'web');
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [confirmPermanent, setConfirmPermanent] = useState(false); const [confirmPermanent, setConfirmPermanent] = useState(false);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { const kind = detectKind(input);
if (visible) setKind(initialType ?? 'web');
}, [visible, initialType]);
const normalizedWeb = kind === 'web' ? normalizeDomain(input) : ''; const normalizedWeb = kind === 'web' ? normalizeDomain(input) : '';
const normalizedMail = kind === 'mail' ? mailDomain(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();
})();
function close() { function close() {
setInput(''); setInput('');
@ -61,31 +59,25 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
onClose(); onClose();
} }
function handleKindChange(next: InputKind) {
if (next === kind) return;
setKind(next);
setInput('');
setError(null);
}
function isInputValid(): boolean { function isInputValid(): boolean {
if (kind === 'web') return isValidDomain(input); if (kind === 'web') return isValidDomain(input);
return input.trim().length > 0; if (kind === 'mail') return normalizedMail.length > 0;
return false;
} }
async function handleAdd() { async function handleAdd() {
if (!isInputValid() || !confirmPermanent || adding) return; if (!isInputValid() || !confirmPermanent || adding) return;
setAdding(true); setAdding(true);
setError(null); setError(null);
const pattern = kind === 'web' ? normalizeDomain(input) : mailPattern; const pattern = kind === 'web' ? normalizeDomain(input) : normalizedMail;
const result = await onAdd(pattern, kind); const result = await onAdd(pattern);
setAdding(false); setAdding(false);
if (result.ok) { if (result.ok) {
close(); close();
return; return;
} }
if (result.alreadyGlobal) { 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')) { } else if (result.error?.includes('WEB_LIMIT_REACHED')) {
setError(t('blocker.error_web_limit_reached')); setError(t('blocker.error_web_limit_reached'));
} else if (result.error?.includes('MAIL_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_free')
: t('blocker.add_sheet_warning_pro'); : 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; const canSubmit = isInputValid() && confirmPermanent && !adding;
return ( return (
@ -127,20 +107,17 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 16, gap: 12 }} contentContainerStyle={{ padding: 16, gap: 12 }}
> >
{/* 1. Type-Picker Pill */} {/* Input field */}
<TypePicker kind={kind} onChange={handleKindChange} colors={colors} />
{/* 2. Input-Field */}
<View style={{ gap: 6 }}> <View style={{ gap: 6 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text }}> <Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
{inputLabel} {t('blocker.add_sheet_label')}
</Text> </Text>
<TextInput <TextInput
value={input} value={input}
onChangeText={(v) => { setInput(v); setError(null); }} onChangeText={(v) => { setInput(v); setError(null); }}
placeholder={inputPlaceholder} placeholder={t('blocker.add_sheet_placeholder')}
placeholderTextColor={colors.textMuted} placeholderTextColor={colors.textMuted}
keyboardType={kind === 'web' ? 'url' : 'email-address'} keyboardType="email-address"
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
style={{ style={{
@ -161,7 +138,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
)} )}
</View> </View>
{/* 3. Help-Text */} {/* Help text */}
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
@ -172,7 +149,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
}} }}
> >
<Ionicons <Ionicons
name={kind === 'web' ? 'globe-outline' : 'mail-outline'} name="information-circle-outline"
size={16} size={16}
color={colors.textMuted} color={colors.textMuted}
style={{ marginTop: 1 }} style={{ marginTop: 1 }}
@ -186,76 +163,21 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
lineHeight: 17, lineHeight: 17,
}} }}
> >
{helpText} {t('blocker.add_sheet_help')}
</Text> </Text>
</View> </View>
{/* 4. Preview-Card */} {/* Preview card */}
{kind === 'web' ? ( <PreviewCard
<View kind={kind}
style={{ normalizedWeb={normalizedWeb}
flexDirection: 'row', normalizedMail={normalizedMail}
alignItems: 'center', placeholder={t('blocker.add_sheet_placeholder')}
gap: 10, colors={colors}
padding: 12, t={t}
backgroundColor: colors.surfaceElevated, />
borderRadius: 12,
}}
>
<Image
source={{ uri: `https://www.google.com/s2/favicons?domain=${normalizedWeb || 'example.com'}&sz=64` }}
style={{ width: 24, height: 24, borderRadius: 4 }}
/>
<Text
style={{
flex: 1,
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: normalizedWeb ? colors.text : colors.textMuted,
}}
numberOfLines={1}
>
{normalizedWeb || inputPlaceholder}
</Text>
</View>
) : (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
}}
>
<View
style={{
width: 24,
height: 24,
borderRadius: 4,
backgroundColor: '#dbeafe',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="mail-outline" size={14} color="#2563eb" />
</View>
<Text
style={{
flex: 1,
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: mailPattern ? colors.text : colors.textMuted,
}}
numberOfLines={1}
>
{mailPattern || inputPlaceholder}
</Text>
</View>
)}
{/* 5. Warning-Card */} {/* Warning card */}
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
@ -281,7 +203,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
</Text> </Text>
</View> </View>
{/* 6. Confirm-Checkbox */} {/* Confirm checkbox */}
<TouchableOpacity <TouchableOpacity
onPress={() => setConfirmPermanent((v) => !v)} onPress={() => setConfirmPermanent((v) => !v)}
activeOpacity={0.7} activeOpacity={0.7}
@ -320,13 +242,9 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{/* 7. Buttons */} {/* Buttons */}
<View style={{ flexDirection: 'row', gap: 10, marginTop: 4 }}> <View style={{ flexDirection: 'row', gap: 10, marginTop: 4 }}>
<TouchableOpacity <TouchableOpacity onPress={close} activeOpacity={0.8} style={{ flex: 1 }}>
onPress={close}
activeOpacity={0.8}
style={{ flex: 1 }}
>
<View <View
style={{ style={{
borderRadius: 14, borderRadius: 14,
@ -372,94 +290,98 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
); );
} }
// ─── TypePicker ────────────────────────────────────────────────────────────── // ─── PreviewCard ──────────────────────────────────────────────────────────────
function TypePicker({ function PreviewCard({
kind, kind,
onChange, normalizedWeb,
normalizedMail,
placeholder,
colors, colors,
t,
}: { }: {
kind: InputKind; kind: 'web' | 'mail' | null;
onChange: (k: InputKind) => void; normalizedWeb: string;
normalizedMail: string;
placeholder: string;
colors: ColorScheme; colors: ColorScheme;
t: (key: string, opts?: Record<string, unknown>) => string;
}) { }) {
const { t } = useTranslation(); if (kind === 'web') {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
}}
>
<Image
source={{ uri: `https://www.google.com/s2/favicons?domain=${normalizedWeb || 'example.com'}&sz=64` }}
style={{ width: 24, height: 24, borderRadius: 4 }}
/>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('blocker.preview_web', { value: normalizedWeb || '…' })}
</Text>
</View>
<Ionicons name="globe-outline" size={16} color={colors.textMuted} />
</View>
);
}
if (kind === 'mail') {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
}}
>
<View
style={{
width: 24,
height: 24,
borderRadius: 4,
backgroundColor: '#dbeafe',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="mail-outline" size={14} color="#2563eb" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('blocker.preview_mail', { value: normalizedMail || '…' })}
</Text>
</View>
<Ionicons name="mail-outline" size={16} color={colors.textMuted} />
</View>
);
}
return ( return (
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 12,
backgroundColor: colors.surfaceElevated, backgroundColor: colors.surfaceElevated,
borderRadius: 12, borderRadius: 12,
padding: 3,
gap: 3,
}} }}
> >
<TypePill <Ionicons name="warning-outline" size={20} color="#dc2626" />
icon="globe-outline" <Text style={{ flex: 1, fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#dc2626' }}>
label={t('blocker.type_web')} {t('blocker.preview_invalid')}
active={kind === 'web'} </Text>
onPress={() => onChange('web')}
colors={colors}
/>
<TypePill
icon="mail-outline"
label={t('blocker.type_mail')}
active={kind === 'mail'}
onPress={() => onChange('mail')}
colors={colors}
/>
</View> </View>
); );
} }
function TypePill({
icon,
label,
active,
onPress,
colors,
}: {
icon: 'globe-outline' | 'mail-outline';
label: string;
active: boolean;
onPress: () => void;
colors: ColorScheme;
}) {
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.8}
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 9,
borderRadius: 10,
backgroundColor: active ? colors.bg : 'transparent',
shadowColor: active ? '#000' : 'transparent',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: active ? 0.08 : 0,
shadowRadius: 2,
elevation: active ? 1 : 0,
}}
>
<Ionicons
name={icon}
size={15}
color={active ? colors.text : colors.textMuted}
/>
<Text
style={{
fontSize: 13,
fontFamily: active ? 'Nunito_700Bold' : 'Nunito_400Regular',
color: active ? colors.text : colors.textMuted,
}}
>
{label}
</Text>
</TouchableOpacity>
);
}

View File

@ -60,7 +60,7 @@ export type UseCustomDomainsReturn = {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
refresh: () => Promise<void>; refresh: () => Promise<void>;
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 }>; submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
removeDomain: (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. */ /** Live-Validate (regex) ob string gültiger Domain-Name ist. */
@ -141,16 +141,19 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
}, [fetchDomains]); }, [fetchDomains]);
const addDomain = useCallback( const addDomain = useCallback(
async (input: string, kind: 'web' | 'mail' = 'web') => { async (input: string, kind?: 'web' | 'mail') => {
if (kind === 'web' && !isValidDomain(input)) return { ok: false, error: 'invalid_domain' }; const resolvedKind: 'web' | 'mail' = kind ?? (input.includes('@') ? 'mail' : 'web');
if (kind === 'mail' && !input.trim()) return { ok: false, error: 'invalid_pattern' }; 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); const tier = deriveTier(plan, domains);
if (tier.atLimit) return { ok: false, error: 'limit_reached' }; 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<string, string> = { pattern };
if (kind !== undefined) body.kind = kind;
try { try {
const res = await apiFetch<any>('/api/custom-domains', { const res = await apiFetch<any>('/api/custom-domains', {
method: 'POST', method: 'POST',
body: { pattern, kind }, body,
}); });
if (res?.alreadyGlobal) { if (res?.alreadyGlobal) {
return { ok: false, alreadyGlobal: true }; return { ok: false, alreadyGlobal: true };

View File

@ -185,10 +185,16 @@
"status_approved": "Genehmigt", "status_approved": "Genehmigt",
"status_rejected": "Abgelehnt", "status_rejected": "Abgelehnt",
"status_pending": "Ausstehend", "status_pending": "Ausstehend",
"add_sheet_title": "Domain blockieren", "add_sheet_title": "Filter hinzufügen",
"add_sheet_label": "Domain", "add_sheet_label": "Domain oder E-Mail-Adresse",
"add_sheet_placeholder": "z.B. bet365.com", "add_sheet_placeholder": "z.B. casino.com oder info@casino.com",
"add_sheet_invalid": "Bitte gültige Domain eingeben (z.B. example.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_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_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.", "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_help": "E-Mail-Adresse oder Mail-Domain. Wir blockieren alle Mails von diesem Absender.",
"add_mail_invalid": "Bitte ein Muster eingeben.", "add_mail_invalid": "Bitte ein Muster eingeben.",
"add_sheet_cta": "Hinzufügen", "add_sheet_cta": "Hinzufügen",
"tabs_web": "Seiten",
"tabs_mail": "Mails",
"section_domains": "Eigene Domains", "section_domains": "Eigene Domains",
"section_mails": "Eigene Mails", "section_mails": "Eigene Mails",
"count_label": "%{count}/%{max}", "count_label": "%{count}/%{max}",

View File

@ -185,10 +185,16 @@
"status_approved": "Approved", "status_approved": "Approved",
"status_rejected": "Rejected", "status_rejected": "Rejected",
"status_pending": "Pending", "status_pending": "Pending",
"add_sheet_title": "Block domain", "add_sheet_title": "Add filter",
"add_sheet_label": "Domain", "add_sheet_label": "Domain or email address",
"add_sheet_placeholder": "e.g. bet365.com", "add_sheet_placeholder": "e.g. casino.com or info@casino.com",
"add_sheet_invalid": "Please enter a valid domain (e.g. example.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_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_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.", "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_help": "Email address or mail domain. We block all emails from this sender.",
"add_mail_invalid": "Please enter a pattern.", "add_mail_invalid": "Please enter a pattern.",
"add_sheet_cta": "Add", "add_sheet_cta": "Add",
"tabs_web": "Websites",
"tabs_mail": "Emails",
"section_domains": "Your Domains", "section_domains": "Your Domains",
"section_mails": "Your Email Filters", "section_mails": "Your Email Filters",
"count_label": "%{count}/%{max}", "count_label": "%{count}/%{max}",

View File

@ -185,10 +185,16 @@
"status_approved": "Approuvé", "status_approved": "Approuvé",
"status_rejected": "Refusé", "status_rejected": "Refusé",
"status_pending": "En attente", "status_pending": "En attente",
"add_sheet_title": "Bloquer un domaine", "add_sheet_title": "Ajouter un filtre",
"add_sheet_label": "Domaine", "add_sheet_label": "Domaine ou adresse e-mail",
"add_sheet_placeholder": "ex. bet365.com", "add_sheet_placeholder": "ex. casino.com ou info@casino.com",
"add_sheet_invalid": "Veuillez saisir un domaine valide (ex. example.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_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_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.", "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_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_mail_invalid": "Veuillez saisir un modèle.",
"add_sheet_cta": "Ajouter", "add_sheet_cta": "Ajouter",
"tabs_web": "Sites",
"tabs_mail": "E-mails",
"section_domains": "Mes domaines", "section_domains": "Mes domaines",
"section_mails": "Mes filtres mail", "section_mails": "Mes filtres mail",
"count_label": "%{count}/%{max}", "count_label": "%{count}/%{max}",