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.
388 lines
11 KiB
TypeScript
388 lines
11 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Image,
|
|
ScrollView,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
View,
|
|
} from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
isValidDomain,
|
|
normalizeDomain,
|
|
type Tier,
|
|
} from '../../hooks/useCustomDomains';
|
|
import { useColors, type ColorScheme } from '../../lib/theme';
|
|
import { FormSheet } from '../FormSheet';
|
|
|
|
type Props = {
|
|
visible: boolean;
|
|
tier: Tier;
|
|
onClose: () => void;
|
|
onAdd: (pattern: string) => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>;
|
|
};
|
|
|
|
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 [input, setInput] = useState('');
|
|
const [confirmPermanent, setConfirmPermanent] = useState(false);
|
|
const [adding, setAdding] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const kind = detectKind(input);
|
|
const normalizedWeb = kind === 'web' ? normalizeDomain(input) : '';
|
|
const normalizedMail = kind === 'mail' ? mailDomain(input) : '';
|
|
|
|
function close() {
|
|
setInput('');
|
|
setConfirmPermanent(false);
|
|
setError(null);
|
|
onClose();
|
|
}
|
|
|
|
function isInputValid(): boolean {
|
|
if (kind === 'web') return isValidDomain(input);
|
|
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) : normalizedMail;
|
|
const result = await onAdd(pattern);
|
|
setAdding(false);
|
|
if (result.ok) {
|
|
close();
|
|
return;
|
|
}
|
|
if (result.alreadyGlobal) {
|
|
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')) {
|
|
setError(t('blocker.error_mail_limit_reached'));
|
|
} else {
|
|
setError(result.error ?? t('blocker.add_sheet_add_failed'));
|
|
}
|
|
}
|
|
|
|
const warningText =
|
|
tier.plan === 'free'
|
|
? t('blocker.add_sheet_warning_free')
|
|
: t('blocker.add_sheet_warning_pro');
|
|
|
|
const canSubmit = isInputValid() && confirmPermanent && !adding;
|
|
|
|
return (
|
|
<FormSheet
|
|
visible={visible}
|
|
onClose={close}
|
|
title={t('blocker.add_sheet_title')}
|
|
initialHeightPct={0.78}
|
|
growWithKeyboard
|
|
>
|
|
<ScrollView
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={{ padding: 16, gap: 12 }}
|
|
>
|
|
{/* Input field */}
|
|
<View style={{ gap: 6 }}>
|
|
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
|
|
{t('blocker.add_sheet_label')}
|
|
</Text>
|
|
<TextInput
|
|
value={input}
|
|
onChangeText={(v) => { setInput(v); setError(null); }}
|
|
placeholder={t('blocker.add_sheet_placeholder')}
|
|
placeholderTextColor={colors.textMuted}
|
|
keyboardType="email-address"
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
style={{
|
|
backgroundColor: colors.surfaceElevated,
|
|
borderRadius: 10,
|
|
padding: 12,
|
|
fontSize: 14,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: colors.text,
|
|
borderWidth: 1,
|
|
borderColor: error ? '#dc2626' : colors.border,
|
|
}}
|
|
/>
|
|
{error && (
|
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#dc2626' }}>
|
|
{error}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* Help text */}
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
padding: 12,
|
|
backgroundColor: colors.surfaceElevated,
|
|
borderRadius: 12,
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name="information-circle-outline"
|
|
size={16}
|
|
color={colors.textMuted}
|
|
style={{ marginTop: 1 }}
|
|
/>
|
|
<Text
|
|
style={{
|
|
flex: 1,
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: colors.textMuted,
|
|
lineHeight: 17,
|
|
}}
|
|
>
|
|
{t('blocker.add_sheet_help')}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Preview card */}
|
|
<PreviewCard
|
|
kind={kind}
|
|
normalizedWeb={normalizedWeb}
|
|
normalizedMail={normalizedMail}
|
|
placeholder={t('blocker.add_sheet_placeholder')}
|
|
colors={colors}
|
|
t={t}
|
|
/>
|
|
|
|
{/* Warning card */}
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
gap: 10,
|
|
padding: 12,
|
|
backgroundColor: '#fef3c7',
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: '#fcd34d',
|
|
}}
|
|
>
|
|
<Ionicons name="lock-closed" size={18} color="#92400e" style={{ marginTop: 1 }} />
|
|
<Text
|
|
style={{
|
|
flex: 1,
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: '#92400e',
|
|
lineHeight: 17,
|
|
}}
|
|
>
|
|
{warningText}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Confirm checkbox */}
|
|
<TouchableOpacity
|
|
onPress={() => setConfirmPermanent((v) => !v)}
|
|
activeOpacity={0.7}
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
gap: 10,
|
|
paddingVertical: 4,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 6,
|
|
borderWidth: 1.5,
|
|
borderColor: confirmPermanent ? colors.success : colors.border,
|
|
backgroundColor: confirmPermanent ? colors.success : colors.bg,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginTop: 1,
|
|
}}
|
|
>
|
|
{confirmPermanent && <Ionicons name="checkmark" size={16} color="#fff" />}
|
|
</View>
|
|
<Text
|
|
style={{
|
|
flex: 1,
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: colors.text,
|
|
lineHeight: 18,
|
|
}}
|
|
>
|
|
{t('blocker.add_sheet_confirm_permanent')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
|
|
{/* Buttons */}
|
|
<View style={{ flexDirection: 'row', gap: 10, marginTop: 4 }}>
|
|
<TouchableOpacity onPress={close} activeOpacity={0.8} style={{ flex: 1 }}>
|
|
<View
|
|
style={{
|
|
borderRadius: 14,
|
|
paddingVertical: 14,
|
|
alignItems: 'center',
|
|
backgroundColor: colors.surfaceElevated,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
|
|
{t('common.cancel')}
|
|
</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
onPress={handleAdd}
|
|
disabled={!canSubmit}
|
|
activeOpacity={0.85}
|
|
style={{ flex: 2 }}
|
|
>
|
|
<View
|
|
style={{
|
|
backgroundColor: canSubmit ? '#dc2626' : '#d4d4d4',
|
|
borderRadius: 14,
|
|
paddingVertical: 14,
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
{adding ? (
|
|
<ActivityIndicator color="#fff" />
|
|
) : (
|
|
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
|
|
{t('blocker.add_sheet_cta')}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</ScrollView>
|
|
</FormSheet>
|
|
);
|
|
}
|
|
|
|
// ─── PreviewCard ──────────────────────────────────────────────────────────────
|
|
|
|
function PreviewCard({
|
|
kind,
|
|
normalizedWeb,
|
|
normalizedMail,
|
|
placeholder,
|
|
colors,
|
|
t,
|
|
}: {
|
|
kind: 'web' | 'mail' | null;
|
|
normalizedWeb: string;
|
|
normalizedMail: string;
|
|
placeholder: string;
|
|
colors: ColorScheme;
|
|
t: (key: string, opts?: Record<string, unknown>) => string;
|
|
}) {
|
|
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,
|
|
}}
|
|
>
|
|
<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>
|
|
);
|
|
}
|