chahinebrini 0a35b58cd9 fix(native): human error messages + kind override checkbox in AddDomainSheet
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.
2026-05-16 03:15:33 +02:00

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>
);
}