chahinebrini 704958320b refactor(domains): gemeinsamer 10/20-Slot-Pool, Free-Tier entfernt
Custom-Domain-Slots sind jetzt EIN gemeinsamer Pool für web + mail
(Pro 10 / Legend 20) statt getrennter web/mail-Buckets. Free-Tier ist
entfallen — PLAN_LIMITS hat nur noch pro + legend, getPlanLimits
defaultet auf pro.

Backend:
- plan-features: customDomains ist eine Zahl (CustomDomainLimits weg)
- index.post: Slot-Check gegen Gesamt-Count, Fehler einheitlich LIMIT_REACHED
- index.get: liefert { items, count, limit }
- change-preview + coach/message an die neue Form angepasst

Frontend:
- useCustomDomains: count/limit (Zahlen) statt countsByType/limits
- AddDomainSheet: ein generischer Limit-Hinweis (error_limit_reached)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 18:40:28 +02:00

523 lines
16 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
ActivityIndicator,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import {
isValidDomain,
normalizeDomain,
type AddDomainResult,
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',
opts?: { addToVip?: boolean },
) => Promise<AddDomainResult>;
};
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);
// Fall 3: Domain ist in Layer 1, aber nicht in der kuratierten VIP. Hält die
// Domain fest, für die der User entscheiden soll, ob er sie zur VIP nimmt.
const [vipPrompt, setVipPrompt] = useState<string | 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) : '';
const inVipMode = vipPrompt !== null;
// Reset override sobald User komplett neuen Input tippt
useEffect(() => {
if (!input) setKindOverride(null);
}, [input]);
function close() {
setInput('');
setKindOverride(null);
setConfirmPermanent(false);
setError(null);
setVipPrompt(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.
const result = await onAdd(pattern, kind === 'mail' ? 'mail' : 'web');
setAdding(false);
if (result.ok) {
close();
return;
}
// Fall 3: in Layer 1, nicht in kuratierter VIP → User entscheidet
if (result.inGlobalNotVip) {
setVipPrompt(pattern);
return;
}
// Fall 2: schon voll geschützt (Layer 1 + kuratierte VIP)
if (result.alreadyProtected) {
setError(t('blocker.add_sheet_already_protected', { domain: pattern }));
return;
}
if (result.alreadyGlobal) {
setError(t('blocker.add_sheet_already_global', { domain: pattern }));
return;
}
const raw = (result.error ?? '').toLowerCase();
if (raw.includes('limit_reached')) {
setError(t('blocker.error_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 {
setError(t('blocker.add_sheet_add_failed'));
}
}
// Fall 3 bestätigt: Domain als VIP-Zweitschutz aufnehmen (Backend speichert
// sie als 'approved' — kein Slot, erscheint nur in der VIP-Liste).
async function handleConfirmVip() {
if (!vipPrompt || adding) return;
setAdding(true);
setError(null);
const result = await onAdd(vipPrompt, 'web', { addToVip: true });
setAdding(false);
if (result.ok) {
close();
return;
}
setVipPrompt(null);
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 canSubmitNormal = isInputValid() && confirmPermanent && !adding;
const ctaEnabled = inVipMode ? !adding : canSubmitNormal;
const ctaColor = inVipMode
? colors.brandOrange
: canSubmitNormal
? '#dc2626'
: '#d4d4d4';
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);
setVipPrompt(null);
}}
editable={!inVipMode}
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,
opacity: inVipMode ? 0.6 : 1,
}}
/>
{error && (
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#dc2626' }}>
{error}
</Text>
)}
</View>
{inVipMode ? (
/* Fall 3: Erklärungs-Karte — Domain ist in Layer 1, kann zusätzlich
in den VIP-Zweitschutz aufgenommen werden. */
<View
style={{
flexDirection: 'row',
gap: 10,
padding: 12,
backgroundColor: '#eff6ff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bfdbfe',
}}
>
<Ionicons name="shield-half" size={18} color="#2563eb" style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#1e3a8a',
lineHeight: 17,
}}
>
{t('blocker.add_sheet_in_global_not_vip', { domain: vipPrompt ?? '' })}
</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={inVipMode ? handleConfirmVip : handleAdd}
disabled={!ctaEnabled}
activeOpacity={0.85}
style={{ flex: 2 }}
>
<View
style={{
backgroundColor: ctaColor,
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
}}
>
{adding ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{inVipMode
? t('blocker.add_sheet_add_to_vip_cta')
: 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>
);
}