Two related fixes after the user saw a raw 400 JSON dump in the sheet
("API 400: { error: true, message: 'Eintrag bereits vorhanden' … }").
1. apiFetch now extracts the prettiest available message from the
response body (data.message → message → statusMessage → raw text →
bare status code) and throws an Error whose .message is that string
only. Stashes the structured pieces on the Error too (.code, .data,
.status) so callers that switch on error codes still have them, but
the default `e?.message` path delivers a clean human sentence.
2. AddDomainSheet maps the known error codes to localized strings —
WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED / INVALID_MAIL_DOMAIN /
DISPLAY_NAME_NOT_SUPPORTED / INVALID_DOMAIN / "Eintrag bereits
vorhanden" (duplicate) — and falls back to a generic copy if the
code is unknown. The raw API JSON never appears in the UI again.
Plus the kind-override checkbox: the auto-detect (input contains "@" →
mail, contains "." → web) is fine for the typical case but a user can
type a clean domain and still want it filtered against mail senders
(e.g. they know "casino.de" is also their casino's sender domain).
The new pill below the preview toggles between mail and web, defaults
to whatever auto-detect said, and resets when the input is cleared. The
local-part strip still runs for mail-mode so the stored value stays a
domain.
i18n: error_invalid_mail / error_invalid_input / error_duplicate /
kind_override_label across DE/EN/FR.
448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
import { useEffect, 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, kind?: 'web' | 'mail') => 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);
|
|
// User-Override über den Auto-Detect. null = follow auto-detect, sonst forced.
|
|
const [kindOverride, setKindOverride] = useState<'web' | 'mail' | null>(null);
|
|
|
|
const detected = detectKind(input);
|
|
const kind: 'web' | 'mail' | null = kindOverride ?? detected;
|
|
const normalizedWeb = kind === 'web' ? normalizeDomain(input) : '';
|
|
const normalizedMail = kind === 'mail' ? mailDomain(input) : '';
|
|
|
|
// Reset override sobald User komplett neuen Input tippt
|
|
useEffect(() => {
|
|
if (!input) setKindOverride(null);
|
|
}, [input]);
|
|
|
|
function close() {
|
|
setInput('');
|
|
setKindOverride(null);
|
|
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;
|
|
// Pass kind explicitly — we've already stripped the local-part for mail,
|
|
// so the backend's auto-detect (which keys on the "@" character) can no
|
|
// longer infer the type from the pattern alone. Without this hint a
|
|
// "info@only4-subscribers.com" entry would land as type=web because
|
|
// the @ disappeared during the strip.
|
|
const result = await onAdd(pattern, kind === 'mail' ? 'mail' : 'web');
|
|
setAdding(false);
|
|
if (result.ok) {
|
|
close();
|
|
return;
|
|
}
|
|
if (result.alreadyGlobal) {
|
|
setError(t('blocker.add_sheet_already_global', { domain: pattern }));
|
|
} else {
|
|
const raw = (result.error ?? '').toLowerCase();
|
|
if (raw.includes('web_limit_reached')) {
|
|
setError(t('blocker.error_web_limit_reached'));
|
|
} else if (raw.includes('mail_limit_reached')) {
|
|
setError(t('blocker.error_mail_limit_reached'));
|
|
} else if (raw.includes('invalid_mail_domain') || raw.includes('display_name_not_supported')) {
|
|
setError(t('blocker.error_invalid_mail'));
|
|
} else if (raw.includes('invalid_domain') || raw.includes('invalid_pattern')) {
|
|
setError(t('blocker.error_invalid_input'));
|
|
} else if (raw.includes('eintrag bereits vorhanden') || raw.includes('duplicate')) {
|
|
setError(t('blocker.error_duplicate'));
|
|
} else {
|
|
// Letzter Fallback: niemals raw JSON anzeigen. Wenn message-Feld da war, kommt's
|
|
// sauber als String an — sonst generic Fehler.
|
|
setError(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}
|
|
/>
|
|
|
|
{/* Override toggle — User kann Auto-Detect korrigieren falls falsch erkannt */}
|
|
{detected !== null && (
|
|
<TouchableOpacity
|
|
onPress={() => setKindOverride(kind === 'mail' ? 'web' : 'mail')}
|
|
activeOpacity={0.7}
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 10,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
backgroundColor: colors.surface,
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name={kind === 'mail' ? 'checkbox' : 'square-outline'}
|
|
size={20}
|
|
color={kind === 'mail' ? colors.brandOrange : colors.textMuted}
|
|
/>
|
|
<Text
|
|
style={{
|
|
flex: 1,
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: colors.text,
|
|
}}
|
|
>
|
|
{t('blocker.kind_override_label')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
{/* 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>
|
|
);
|
|
}
|