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:
parent
a2680f6e19
commit
8a6ab6fe64
@ -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() {
|
||||
</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 */}
|
||||
<DomainSection
|
||||
title={t('blocker.section_domains')}
|
||||
count={countsByType.web}
|
||||
max={limits.web}
|
||||
onAdd={() => openAddSheet('web')}
|
||||
atLimit={countsByType.web >= limits.web}
|
||||
>
|
||||
<DomainGrid
|
||||
@ -387,7 +389,6 @@ export default function BlockerScreen() {
|
||||
collapsible
|
||||
open={mailOpen}
|
||||
onToggle={() => setMailOpen((v) => !v)}
|
||||
onAdd={() => openAddSheet('mail')}
|
||||
atLimit={countsByType.mail >= limits.mail}
|
||||
>
|
||||
<DomainGrid
|
||||
@ -404,13 +405,12 @@ export default function BlockerScreen() {
|
||||
<AddDomainSheet
|
||||
visible={addSheetOpen}
|
||||
tier={tier}
|
||||
initialType={addSheetKind}
|
||||
onClose={() => {
|
||||
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, 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
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({
|
||||
/>
|
||||
</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 */}
|
||||
{children}
|
||||
</View>
|
||||
|
||||
@ -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<InputKind>(initialType ?? 'web');
|
||||
const [input, setInput] = useState('');
|
||||
const [confirmPermanent, setConfirmPermanent] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 */}
|
||||
<TypePicker kind={kind} onChange={handleKindChange} colors={colors} />
|
||||
|
||||
{/* 2. Input-Field */}
|
||||
{/* Input field */}
|
||||
<View style={{ gap: 6 }}>
|
||||
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
|
||||
{inputLabel}
|
||||
{t('blocker.add_sheet_label')}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChangeText={(v) => { 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
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 3. Help-Text */}
|
||||
{/* Help text */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
@ -172,7 +149,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={kind === 'web' ? 'globe-outline' : 'mail-outline'}
|
||||
name="information-circle-outline"
|
||||
size={16}
|
||||
color={colors.textMuted}
|
||||
style={{ marginTop: 1 }}
|
||||
@ -186,76 +163,21 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
lineHeight: 17,
|
||||
}}
|
||||
>
|
||||
{helpText}
|
||||
{t('blocker.add_sheet_help')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 4. Preview-Card */}
|
||||
{kind === 'web' ? (
|
||||
<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 }}
|
||||
{/* Preview card */}
|
||||
<PreviewCard
|
||||
kind={kind}
|
||||
normalizedWeb={normalizedWeb}
|
||||
normalizedMail={normalizedMail}
|
||||
placeholder={t('blocker.add_sheet_placeholder')}
|
||||
colors={colors}
|
||||
t={t}
|
||||
/>
|
||||
<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
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
@ -281,7 +203,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 6. Confirm-Checkbox */}
|
||||
{/* Confirm checkbox */}
|
||||
<TouchableOpacity
|
||||
onPress={() => setConfirmPermanent((v) => !v)}
|
||||
activeOpacity={0.7}
|
||||
@ -320,13 +242,9 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 7. Buttons */}
|
||||
{/* Buttons */}
|
||||
<View style={{ flexDirection: 'row', gap: 10, marginTop: 4 }}>
|
||||
<TouchableOpacity
|
||||
onPress={close}
|
||||
activeOpacity={0.8}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<TouchableOpacity onPress={close} activeOpacity={0.8} style={{ flex: 1 }}>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
@ -372,94 +290,98 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TypePicker ──────────────────────────────────────────────────────────────
|
||||
// ─── PreviewCard ──────────────────────────────────────────────────────────────
|
||||
|
||||
function TypePicker({
|
||||
function PreviewCard({
|
||||
kind,
|
||||
onChange,
|
||||
normalizedWeb,
|
||||
normalizedMail,
|
||||
placeholder,
|
||||
colors,
|
||||
t,
|
||||
}: {
|
||||
kind: InputKind;
|
||||
onChange: (k: InputKind) => void;
|
||||
kind: 'web' | 'mail' | null;
|
||||
normalizedWeb: string;
|
||||
normalizedMail: string;
|
||||
placeholder: string;
|
||||
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 (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: 12,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
borderRadius: 12,
|
||||
padding: 3,
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<TypePill
|
||||
icon="globe-outline"
|
||||
label={t('blocker.type_web')}
|
||||
active={kind === 'web'}
|
||||
onPress={() => onChange('web')}
|
||||
colors={colors}
|
||||
/>
|
||||
<TypePill
|
||||
icon="mail-outline"
|
||||
label={t('blocker.type_mail')}
|
||||
active={kind === 'mail'}
|
||||
onPress={() => onChange('mail')}
|
||||
colors={colors}
|
||||
/>
|
||||
<Ionicons name="warning-outline" size={20} color="#dc2626" />
|
||||
<Text style={{ flex: 1, fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#dc2626' }}>
|
||||
{t('blocker.preview_invalid')}
|
||||
</Text>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ export type UseCustomDomainsReturn = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
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 }>;
|
||||
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<string, string> = { pattern };
|
||||
if (kind !== undefined) body.kind = kind;
|
||||
try {
|
||||
const res = await apiFetch<any>('/api/custom-domains', {
|
||||
method: 'POST',
|
||||
body: { pattern, kind },
|
||||
body,
|
||||
});
|
||||
if (res?.alreadyGlobal) {
|
||||
return { ok: false, alreadyGlobal: true };
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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}",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user