The Seiten/Mails top-tabs added in 5c6fa3d are gone. Per the user's revised vision, web-domains and mail-patterns live side by side as two collapsible <DomainSection>s with their own header, slot pill, progress bar, and add-button — closer to the original Eigene-Domains affordance plus a sibling Eigene-Mails section. Both default open; chevron-up/down per the existing icon convention. AddDomainSheet was rewritten from scratch to fix the layout-bug visible in the screenshot — SheetFieldStack's two-ScrollView intro/ fields split was wrong for a single-input use case and was rendering the chip at the bottom of the scroll area with a huge gap under the TypePicker. The new sheet is a plain ScrollView with TypePicker, label, TextInput, help-card, preview-card, warning-card, confirm-row, and the Cancel + Hinzufügen buttons stacked top-to-bottom with `gap: 12`. No Pressable anywhere — TouchableOpacity only, per the hard rule. DomainGrid is now a pure tile renderer: the header / slot pill / add affordance live on the section component above it. Its `kind` prop (renamed from `activeTab`) drives the type filter — for v1.0, mail means strictly `mail_domain` (display-name is gone). i18n: new keys section_domains / section_mails / add_sheet_cta. mail- related copy (label, placeholder, help, empty) had every "Display-Name" mention stripped so the user can't read about an option that doesn't ship. Progressbar inline in DomainSection with the same Animated.timing pattern DeviceProgressBar uses, with a 3-step color threshold (green / brandOrange / error) keyed on the bucket fill ratio.
466 lines
13 KiB
TypeScript
466 lines
13 KiB
TypeScript
import { useState, useEffect } 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 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 }>;
|
|
};
|
|
|
|
export function AddDomainSheet({ visible, tier, initialType, 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 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();
|
|
})();
|
|
|
|
function close() {
|
|
setInput('');
|
|
setConfirmPermanent(false);
|
|
setError(null);
|
|
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;
|
|
}
|
|
|
|
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);
|
|
setAdding(false);
|
|
if (result.ok) {
|
|
close();
|
|
return;
|
|
}
|
|
if (result.alreadyGlobal) {
|
|
setError(t('blocker.add_sheet_already_global', { domain: normalizedWeb || input.trim() }));
|
|
} 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 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 (
|
|
<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 }}
|
|
>
|
|
{/* 1. Type-Picker Pill */}
|
|
<TypePicker kind={kind} onChange={handleKindChange} colors={colors} />
|
|
|
|
{/* 2. Input-Field */}
|
|
<View style={{ gap: 6 }}>
|
|
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
|
|
{inputLabel}
|
|
</Text>
|
|
<TextInput
|
|
value={input}
|
|
onChangeText={(v) => { setInput(v); setError(null); }}
|
|
placeholder={inputPlaceholder}
|
|
placeholderTextColor={colors.textMuted}
|
|
keyboardType={kind === 'web' ? 'url' : '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>
|
|
|
|
{/* 3. Help-Text */}
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
padding: 12,
|
|
backgroundColor: colors.surfaceElevated,
|
|
borderRadius: 12,
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name={kind === 'web' ? 'globe-outline' : 'mail-outline'}
|
|
size={16}
|
|
color={colors.textMuted}
|
|
style={{ marginTop: 1 }}
|
|
/>
|
|
<Text
|
|
style={{
|
|
flex: 1,
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: colors.textMuted,
|
|
lineHeight: 17,
|
|
}}
|
|
>
|
|
{helpText}
|
|
</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 }}
|
|
/>
|
|
<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 */}
|
|
<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>
|
|
|
|
{/* 6. 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>
|
|
|
|
{/* 7. 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>
|
|
);
|
|
}
|
|
|
|
// ─── TypePicker ──────────────────────────────────────────────────────────────
|
|
|
|
function TypePicker({
|
|
kind,
|
|
onChange,
|
|
colors,
|
|
}: {
|
|
kind: InputKind;
|
|
onChange: (k: InputKind) => void;
|
|
colors: ColorScheme;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
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}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|