feat(native/blocker): two collapsible sections + new AddDomainSheet layout
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.
This commit is contained in:
parent
c1250836a3
commit
f4da81f551
@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ScrollView, Text, View, Alert, ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||
import { Animated, ScrollView, Text, View, Alert, ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import type { CountsByType, LimitsByType } from '../../hooks/useCustomDomains';
|
||||
import { AppHeader } from '../../components/AppHeader';
|
||||
import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard';
|
||||
import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard';
|
||||
@ -52,9 +51,6 @@ export default function BlockerScreen() {
|
||||
const { sync: syncBlocklist } = useBlocklistSync();
|
||||
|
||||
// Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen.
|
||||
// Bei domain_rejected wird die Row backend-seitig hard-deleted → refetch
|
||||
// entfernt sie aus der Liste. Zusätzlich blocklist.bin neu syncen damit
|
||||
// die lokale Hash-Liste nicht aus dem Tritt gerät.
|
||||
const onDomainChange = useCallback(async () => {
|
||||
await refreshDomains();
|
||||
if (urlFilterActiveRef.current) {
|
||||
@ -65,28 +61,26 @@ export default function BlockerScreen() {
|
||||
}, [refreshDomains, syncBlocklist, refresh]);
|
||||
useDomainSubmissionRealtime(onDomainChange, true);
|
||||
|
||||
// Tab-State
|
||||
const [activeTab, setActiveTab] = useState<'web' | 'mail'>('web');
|
||||
// Section collapse state — beide Sections starten offen
|
||||
const [webOpen, setWebOpen] = useState(true);
|
||||
const [mailOpen, setMailOpen] = useState(true);
|
||||
|
||||
// Sheet-States
|
||||
// AddSheet state: tracks which section opened it
|
||||
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||
const [addSheetKind, setAddSheetKind] = useState<'web' | 'mail'>('web');
|
||||
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [explainerOpen, setExplainerOpen] = useState(false);
|
||||
|
||||
// Layer-Status (auf iOS): urlFilter + familyControls.
|
||||
// AppDeletionLock=true bedeutet "locked in" → keine Switches mehr, nur Cooldown-Pfad.
|
||||
const urlFilterActive = state?.layers.urlFilter === true;
|
||||
const familyControlsActive = state?.layers.familyControls === true;
|
||||
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
|
||||
const lockedIn = appDeletionLockActive;
|
||||
|
||||
// Ref damit onDomainChange nicht neu rendert bei jedem urlFilter-Toggle
|
||||
const urlFilterActiveRef = useRef(urlFilterActive);
|
||||
useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]);
|
||||
|
||||
// Auto-Sync wenn URL-Filter beim Page-Mount/-Resume schon aktiv ist und
|
||||
// blocklist.bin leer/stale sein könnte. Dedupe via Ref damit wir nicht
|
||||
// bei jedem Re-Render neu syncen.
|
||||
// Auto-Sync wenn URL-Filter beim Page-Mount/-Resume schon aktiv ist.
|
||||
const syncedOnceRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!urlFilterActive) return;
|
||||
@ -94,7 +88,7 @@ export default function BlockerScreen() {
|
||||
syncedOnceRef.current = true;
|
||||
syncBlocklist().then((res) => {
|
||||
console.log('[blocker] auto-sync on mount:', res);
|
||||
if (res.ok) refresh(); // Stats-Card neu rendern mit aktuellem Count
|
||||
if (res.ok) refresh();
|
||||
});
|
||||
}, [urlFilterActive, syncBlocklist, refresh]);
|
||||
|
||||
@ -114,12 +108,9 @@ export default function BlockerScreen() {
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// Filter ist aktiv aber blocklist.bin ist initial leer — sofort syncen!
|
||||
// Sonst zeigt iOS "Läuft" aber blockt nichts.
|
||||
const sync = await syncBlocklist();
|
||||
console.log('[blocker] post-activate sync:', sync);
|
||||
if (sync.ok) {
|
||||
// Stats-Card neu rendern mit dem frisch geschriebenen Count
|
||||
await refresh();
|
||||
} else {
|
||||
Alert.alert(
|
||||
@ -140,10 +131,8 @@ export default function BlockerScreen() {
|
||||
try {
|
||||
const result = await activateFamilyControls();
|
||||
console.log('[blocker] activateFamilyControls:', result);
|
||||
// `accessibility_pending` = die a11y-Berechtigung fehlt noch und wir haben
|
||||
// grad die System-Settings geöffnet → das IST das Feedback. Kein Fehler-
|
||||
// Modal (sonst Modal-Loop bei jedem Tap). a11y wird nur beim ersten
|
||||
// Einrichten geholt; danach ist das hier ein 1-Tap-Arm ohne Dialog.
|
||||
// `accessibility_pending` = a11y-Berechtigung fehlt noch → System-Settings wurden
|
||||
// geöffnet. Kein Fehler-Modal (sonst Modal-Loop bei jedem Tap).
|
||||
if (!result.enabled && result.error !== 'accessibility_pending') {
|
||||
Alert.alert(
|
||||
t('blocker.activate_app_lock_failed_title'),
|
||||
@ -199,8 +188,6 @@ export default function BlockerScreen() {
|
||||
}
|
||||
if (bypassAlertShownRef.current) return;
|
||||
bypassAlertShownRef.current = true;
|
||||
// Schutz-Filter ist aus, sollte aber an sein → Reaktivierung setzt NUR den
|
||||
// VPN/Filter wieder (kein a11y-Prompt — das passiert nur beim ersten Mal).
|
||||
Alert.alert(
|
||||
t('blocker.protection_off_title'),
|
||||
t('blocker.protection_off_message'),
|
||||
@ -211,6 +198,11 @@ export default function BlockerScreen() {
|
||||
);
|
||||
}, [state?.phase, t]);
|
||||
|
||||
function openAddSheet(kind: 'web' | 'mail') {
|
||||
setAddSheetKind(kind);
|
||||
setAddSheetOpen(true);
|
||||
}
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
@ -226,7 +218,7 @@ export default function BlockerScreen() {
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: tabBarHeight + 80, // platz für FAB + Tab-Bar
|
||||
paddingBottom: tabBarHeight + 80,
|
||||
gap: 14,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@ -235,7 +227,6 @@ export default function BlockerScreen() {
|
||||
{lockedIn ? (
|
||||
<ProtectionLockedCard state={state} onPressSettings={openDetails} />
|
||||
) : (
|
||||
// FC nicht aktiv → User kann pro Layer einzeln togglen
|
||||
<View style={{ gap: 10 }}>
|
||||
<LayerSwitchCard
|
||||
icon="globe-outline"
|
||||
@ -322,7 +313,7 @@ export default function BlockerScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* CooldownBanner — nur wenn Cooldown läuft */}
|
||||
{/* CooldownBanner */}
|
||||
{state.cooldown.active && (
|
||||
<CooldownBanner
|
||||
remainingFormatted={cooldownRemainingFormatted}
|
||||
@ -351,7 +342,7 @@ export default function BlockerScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Über-Limit: Custom-Domain-Banner */}
|
||||
{/* Über-Limit Banner */}
|
||||
{tier.atLimit && tier.usedSlots > tier.domainLimit && (
|
||||
<View
|
||||
style={{
|
||||
@ -373,32 +364,50 @@ export default function BlockerScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Top-Tabs: Seiten / Mails */}
|
||||
<BlockerTabBar
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
countsByType={countsByType}
|
||||
limits={limits}
|
||||
/>
|
||||
|
||||
{/* Domain Grid mit inline + Button neben SlotPill */}
|
||||
<View style={{ marginTop: 8 }}>
|
||||
{/* Section 1: Eigene Domains */}
|
||||
<DomainSection
|
||||
title={t('blocker.section_domains')}
|
||||
count={countsByType.web}
|
||||
max={limits.web}
|
||||
open={webOpen}
|
||||
onToggle={() => setWebOpen((v) => !v)}
|
||||
onAdd={() => openAddSheet('web')}
|
||||
atLimit={countsByType.web >= limits.web}
|
||||
>
|
||||
<DomainGrid
|
||||
domains={domains}
|
||||
tier={tier}
|
||||
activeTab={activeTab}
|
||||
onAdd={() => setAddSheetOpen(true)}
|
||||
kind="web"
|
||||
onSubmit={submitDomain}
|
||||
onUpgradePro={() => Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))}
|
||||
/>
|
||||
</View>
|
||||
</DomainSection>
|
||||
|
||||
{/* Section 2: Eigene Mails */}
|
||||
<DomainSection
|
||||
title={t('blocker.section_mails')}
|
||||
count={countsByType.mail}
|
||||
max={limits.mail}
|
||||
open={mailOpen}
|
||||
onToggle={() => setMailOpen((v) => !v)}
|
||||
onAdd={() => openAddSheet('mail')}
|
||||
atLimit={countsByType.mail >= limits.mail}
|
||||
>
|
||||
<DomainGrid
|
||||
domains={domains}
|
||||
tier={tier}
|
||||
kind="mail"
|
||||
onSubmit={submitDomain}
|
||||
onUpgradePro={() => Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))}
|
||||
/>
|
||||
</DomainSection>
|
||||
</ScrollView>
|
||||
|
||||
{/* Sheets */}
|
||||
<AddDomainSheet
|
||||
visible={addSheetOpen}
|
||||
tier={tier}
|
||||
initialType={activeTab}
|
||||
initialType={addSheetKind}
|
||||
onClose={() => {
|
||||
setAddSheetOpen(false);
|
||||
refreshDomains();
|
||||
@ -406,9 +415,8 @@ export default function BlockerScreen() {
|
||||
onAdd={async (pattern, kind) => {
|
||||
const result = await addDomain(pattern, kind);
|
||||
if (result.ok) {
|
||||
// Neue Custom-Domain → Filter muss aktualisierten Hash-Set kriegen
|
||||
const sync = await syncBlocklist();
|
||||
if (sync.ok) refresh(); // Stats-Card mit neuem Count refreshen
|
||||
if (sync.ok) refresh();
|
||||
}
|
||||
return result;
|
||||
}}
|
||||
@ -434,87 +442,152 @@ export default function BlockerScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── BlockerTabBar ────────────────────────────────────────────────────────────
|
||||
// ─── DomainSection ────────────────────────────────────────────────────────────
|
||||
|
||||
function BlockerTabBar({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
countsByType,
|
||||
limits,
|
||||
function DomainSection({
|
||||
title,
|
||||
count,
|
||||
max,
|
||||
open,
|
||||
onToggle,
|
||||
onAdd,
|
||||
atLimit,
|
||||
children,
|
||||
}: {
|
||||
activeTab: 'web' | 'mail';
|
||||
onTabChange: (tab: 'web' | 'mail') => void;
|
||||
countsByType: CountsByType;
|
||||
limits: LimitsByType;
|
||||
title: string;
|
||||
count: number;
|
||||
max: number;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onAdd: () => void;
|
||||
atLimit: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
|
||||
const tabs: { key: 'web' | 'mail'; label: string; count: number; max: number }[] = [
|
||||
{ key: 'web', label: t('blocker.tabs_web'), count: countsByType.web, max: limits.web },
|
||||
{ key: 'mail', label: t('blocker.tabs_mail'), count: countsByType.mail, max: limits.mail },
|
||||
];
|
||||
// Animated progress bar
|
||||
const fillAnim = useRef(new Animated.Value(0)).current;
|
||||
const ratio = max > 0 ? Math.min(count / max, 1) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(fillAnim, {
|
||||
toValue: ratio,
|
||||
duration: 380,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
}, [ratio]);
|
||||
|
||||
const pct = ratio * 100;
|
||||
const barColor = pct >= 90 ? '#dc2626' : pct >= 60 ? '#f59e0b' : '#16a34a';
|
||||
|
||||
const badgeBg = atLimit ? '#fee2e2' : colors.surfaceElevated;
|
||||
const badgeFg = atLimit ? '#dc2626' : colors.textMuted;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
marginBottom: 4,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.key;
|
||||
const atMax = tab.count >= tab.max;
|
||||
const badgeColor = atMax ? colors.error : colors.textMuted;
|
||||
{/* Section Header */}
|
||||
<TouchableOpacity
|
||||
onPress={onToggle}
|
||||
activeOpacity={0.7}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ flex: 1, fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||
{title}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 999,
|
||||
backgroundColor: badgeBg,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: badgeFg }}>
|
||||
{t('blocker.count_label', { count, max })}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name={open ? 'chevron-up' : 'chevron-down'}
|
||||
size={16}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
onPress={() => onTabChange(tab.key)}
|
||||
activeOpacity={0.7}
|
||||
{open && (
|
||||
<View style={{ paddingHorizontal: 14, paddingBottom: 14, gap: 12 }}>
|
||||
{/* Progressbar */}
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingBottom: 10,
|
||||
paddingTop: 4,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: isActive ? colors.brandOrange : 'transparent',
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Animated.View
|
||||
style={{
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
backgroundColor: barColor,
|
||||
width: fillAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0%', '100%'],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Add-Button */}
|
||||
<TouchableOpacity
|
||||
onPress={atLimit ? undefined : onAdd}
|
||||
disabled={atLimit}
|
||||
activeOpacity={0.75}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 7,
|
||||
borderRadius: 10,
|
||||
backgroundColor: atLimit ? colors.surfaceElevated : '#007AFF',
|
||||
opacity: atLimit ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add" size={16} color={atLimit ? colors.textMuted : '#fff'} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontFamily: isActive ? 'Nunito_700Bold' : 'Nunito_400Regular',
|
||||
color: isActive ? colors.text : colors.textMuted,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: atLimit ? colors.textMuted : '#fff',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
{t('blocker.add_domain')}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 999,
|
||||
backgroundColor: atMax ? '#fee2e2' : colors.surfaceElevated,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: badgeColor,
|
||||
}}
|
||||
>
|
||||
{t('blocker.count_label', { count: tab.count, max: tab.max })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Grid */}
|
||||
{children}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,9 @@ import { useState, useEffect } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
@ -15,7 +17,6 @@ import {
|
||||
} from '../../hooks/useCustomDomains';
|
||||
import { useColors, type ColorScheme } from '../../lib/theme';
|
||||
import { FormSheet } from '../FormSheet';
|
||||
import { SheetFieldStack } from '../SheetFieldStack';
|
||||
|
||||
type InputKind = 'web' | 'mail';
|
||||
|
||||
@ -35,7 +36,6 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
const [confirmPermanent, setConfirmPermanent] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldsDone, setFieldsDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) setKind(initialType ?? 'web');
|
||||
@ -44,17 +44,13 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
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 — casino affiliates rotate the
|
||||
// local-part rapidly (communications@, newsletter@, info@, …) while the
|
||||
// sender-domain stays stable. Blocking on the domain is more durable.
|
||||
// A bare token without "@" stays as-is (will be matched as display-name on
|
||||
// the backend).
|
||||
// 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;
|
||||
if (atIdx === -1) return raw.toLowerCase();
|
||||
return raw.slice(atIdx + 1).trim().toLowerCase();
|
||||
})();
|
||||
|
||||
@ -62,7 +58,6 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
setInput('');
|
||||
setConfirmPermanent(false);
|
||||
setError(null);
|
||||
setFieldsDone(false);
|
||||
onClose();
|
||||
}
|
||||
|
||||
@ -71,7 +66,6 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
setKind(next);
|
||||
setInput('');
|
||||
setError(null);
|
||||
setFieldsDone(false);
|
||||
}
|
||||
|
||||
function isInputValid(): boolean {
|
||||
@ -83,7 +77,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
if (!isInputValid() || !confirmPermanent || adding) return;
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
const pattern = kind === 'web' ? input : mailPattern;
|
||||
const pattern = kind === 'web' ? normalizeDomain(input) : mailPattern;
|
||||
const result = await onAdd(pattern, kind);
|
||||
setAdding(false);
|
||||
if (result.ok) {
|
||||
@ -118,39 +112,56 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
? t('blocker.add_web_help')
|
||||
: t('blocker.add_mail_help');
|
||||
|
||||
const validateField = kind === 'web'
|
||||
? (v: string) => isValidDomain(v) ? undefined : t('blocker.add_sheet_invalid')
|
||||
: (v: string) => v.trim().length > 0 ? undefined : t('blocker.add_mail_invalid');
|
||||
const canSubmit = isInputValid() && confirmPermanent && !adding;
|
||||
|
||||
return (
|
||||
<FormSheet
|
||||
visible={visible}
|
||||
onClose={close}
|
||||
title={t('blocker.add_sheet_title')}
|
||||
initialHeightPct={0.75}
|
||||
initialHeightPct={0.78}
|
||||
growWithKeyboard
|
||||
>
|
||||
<SheetFieldStack
|
||||
intro={
|
||||
<TypePicker kind={kind} onChange={handleKindChange} />
|
||||
}
|
||||
fields={[
|
||||
{
|
||||
key: 'pattern',
|
||||
label: inputLabel,
|
||||
placeholder: inputPlaceholder,
|
||||
value: input,
|
||||
onChangeText: (v) => { setInput(v); setError(null); },
|
||||
normalize: kind === 'web' ? normalizeDomain : undefined,
|
||||
keyboardType: kind === 'web' ? 'url' : 'default',
|
||||
autoCapitalize: 'none',
|
||||
autoCorrect: false,
|
||||
validate: validateField,
|
||||
},
|
||||
]}
|
||||
onComplete={() => setFieldsDone(true)}
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ padding: 16, gap: 12 }}
|
||||
>
|
||||
{/* Help-Text */}
|
||||
{/* 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',
|
||||
@ -158,7 +169,6 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
padding: 12,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
@ -180,8 +190,8 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Favicon-Preview (nur Web) */}
|
||||
{kind === 'web' && (
|
||||
{/* 4. Preview-Card */}
|
||||
{kind === 'web' ? (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
@ -190,13 +200,10 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
padding: 12,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: `https://www.google.com/s2/favicons?domain=${normalizedWeb}&sz=64`,
|
||||
}}
|
||||
source={{ uri: `https://www.google.com/s2/favicons?domain=${normalizedWeb || 'example.com'}&sz=64` }}
|
||||
style={{ width: 24, height: 24, borderRadius: 4 }}
|
||||
/>
|
||||
<Text
|
||||
@ -204,17 +211,14 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: colors.text,
|
||||
color: normalizedWeb ? colors.text : colors.textMuted,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{normalizedWeb}
|
||||
{normalizedWeb || inputPlaceholder}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Mail-Typ Icon-Preview */}
|
||||
{kind === 'mail' && (
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
@ -223,7 +227,6 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
padding: 12,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
@ -243,7 +246,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: colors.text,
|
||||
color: mailPattern ? colors.text : colors.textMuted,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@ -252,7 +255,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Warnung */}
|
||||
{/* 5. Warning-Card */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
@ -262,10 +265,9 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#fcd34d',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="lock-closed" size={18} color="#92400e" />
|
||||
<Ionicons name="lock-closed" size={18} color="#92400e" style={{ marginTop: 1 }} />
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
@ -279,7 +281,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Confirm-Checkbox */}
|
||||
{/* 6. Confirm-Checkbox */}
|
||||
<TouchableOpacity
|
||||
onPress={() => setConfirmPermanent((v) => !v)}
|
||||
activeOpacity={0.7}
|
||||
@ -288,7 +290,6 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
paddingVertical: 4,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
@ -319,53 +320,70 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{error && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: '#dc2626',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
{/* 7. Buttons */}
|
||||
<View style={{ flexDirection: 'row', gap: 10, marginTop: 4 }}>
|
||||
<TouchableOpacity
|
||||
onPress={close}
|
||||
activeOpacity={0.8}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Add-Button */}
|
||||
<TouchableOpacity
|
||||
onPress={handleAdd}
|
||||
disabled={!confirmPermanent || adding}
|
||||
activeOpacity={0.85}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: !confirmPermanent ? '#d4d4d4' : '#dc2626',
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{adding ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
|
||||
{t('blocker.add_sheet_title')}
|
||||
<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>
|
||||
</SheetFieldStack>
|
||||
</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 }: { kind: InputKind; onChange: (k: InputKind) => void }) {
|
||||
function TypePicker({
|
||||
kind,
|
||||
onChange,
|
||||
colors,
|
||||
}: {
|
||||
kind: InputKind;
|
||||
onChange: (k: InputKind) => void;
|
||||
colors: ColorScheme;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||
@ -32,27 +32,11 @@ function timeAgo(input?: string | Date): string {
|
||||
return `${months}mo`;
|
||||
}
|
||||
|
||||
function timeSinceSubmit(input?: string | Date): string {
|
||||
if (!input) return '';
|
||||
const date = typeof input === 'string' ? new Date(input) : input;
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const hours = Math.floor(diffMs / 3_600_000);
|
||||
if (hours < 1) {
|
||||
const minutes = Math.max(1, Math.floor(diffMs / 60_000));
|
||||
return `${minutes} min`;
|
||||
}
|
||||
if (hours < 24) return `${hours} Std`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} Tag${days === 1 ? '' : 'e'}`;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
domains: CustomDomain[];
|
||||
tier: Tier;
|
||||
activeTab: 'web' | 'mail';
|
||||
onAdd?: () => void;
|
||||
kind: 'web' | 'mail';
|
||||
onSubmit?: (id: string) => Promise<{ ok: boolean }>;
|
||||
onRemove?: (id: string) => Promise<{ ok: boolean }>;
|
||||
onUpgradePro?: () => void;
|
||||
};
|
||||
|
||||
@ -65,16 +49,16 @@ const STATUS_PRIORITY: Record<string, number> = {
|
||||
approved: 99,
|
||||
};
|
||||
|
||||
export function DomainGrid({ domains, tier, activeTab, onAdd, onSubmit, onUpgradePro }: Props) {
|
||||
export function DomainGrid({ domains, tier, kind, onSubmit, onUpgradePro }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
// Filter by tab, then by status (exclude approved). Sort by status-priority, then newest-first.
|
||||
|
||||
const visible = useMemo(() => {
|
||||
return domains
|
||||
.filter((d) => {
|
||||
if (d.status === 'approved') return false;
|
||||
if (activeTab === 'mail') {
|
||||
return d.type === 'mail_domain' || d.type === 'mail_display_name';
|
||||
if (kind === 'mail') {
|
||||
return d.type === 'mail_domain';
|
||||
}
|
||||
return d.type === 'web' || !d.type;
|
||||
})
|
||||
@ -87,60 +71,10 @@ export function DomainGrid({ domains, tier, activeTab, onAdd, onSubmit, onUpgrad
|
||||
const tb = b.addedAt ? new Date(b.addedAt).getTime() : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
}, [domains, activeTab]);
|
||||
}, [domains, kind]);
|
||||
|
||||
return (
|
||||
<View style={{ gap: 12 }}>
|
||||
{/* Header: Section-Title + Slot-Counter + Add-Button (inline, neben SlotPill) */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||
{t('blocker.domain_section_title')}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<SlotPill tier={tier} />
|
||||
{onAdd && (
|
||||
<TouchableOpacity
|
||||
onPress={tier.atLimit ? undefined : onAdd}
|
||||
accessibilityLabel={t('blocker.domain_add_a11y')}
|
||||
disabled={tier.atLimit}
|
||||
activeOpacity={0.75}
|
||||
hitSlop={8}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: tier.atLimit ? '#a3a3a3' : '#007AFF',
|
||||
opacity: tier.atLimit ? 0.5 : 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add" size={22} color="#fff" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Progress-Bar — 3-stufige Color-Schwelle: <60% grün, 60-90% orange, >=90% rot */}
|
||||
{(() => {
|
||||
const pct = (tier.usedSlots / tier.domainLimit) * 100;
|
||||
const barColor = pct >= 90 ? '#dc2626' : pct >= 60 ? '#f59e0b' : '#16a34a';
|
||||
return (
|
||||
<View style={{ height: 4, borderRadius: 2, backgroundColor: colors.surfaceElevated, overflow: 'hidden' }}>
|
||||
<View
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${Math.min(100, pct)}%`,
|
||||
backgroundColor: barColor,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Limit-Reached Upsell (nur Free) */}
|
||||
{tier.atLimit && tier.plan === 'free' && (
|
||||
<TouchableOpacity
|
||||
@ -184,7 +118,7 @@ export function DomainGrid({ domains, tier, activeTab, onAdd, onSubmit, onUpgrad
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={activeTab === 'mail' ? 'mail-outline' : 'globe-outline'}
|
||||
name={kind === 'mail' ? 'mail-outline' : 'globe-outline'}
|
||||
size={28}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
@ -197,7 +131,7 @@ export function DomainGrid({ domains, tier, activeTab, onAdd, onSubmit, onUpgrad
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{activeTab === 'mail' ? t('blocker.empty_mail') : t('blocker.empty_web')}
|
||||
{kind === 'mail' ? t('blocker.empty_mail') : t('blocker.empty_web')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
@ -211,28 +145,6 @@ export function DomainGrid({ domains, tier, activeTab, onAdd, onSubmit, onUpgrad
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SlotPill ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SlotPill({ tier }: { tier: Tier }) {
|
||||
const colors = useColors();
|
||||
const bg = tier.atLimit ? '#fee2e2' : colors.surfaceElevated;
|
||||
const fg = tier.atLimit ? '#dc2626' : colors.textMuted;
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 999,
|
||||
backgroundColor: bg,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: fg }}>
|
||||
{tier.usedSlots}/{tier.domainLimit}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tiles ────────────────────────────────────────────────────────────────
|
||||
|
||||
function DomainTilesGrid({
|
||||
@ -245,7 +157,7 @@ function DomainTilesGrid({
|
||||
onSubmit?: (id: string) => Promise<{ ok: boolean }>;
|
||||
}) {
|
||||
// 3-Spalten-Grid via flex-wrap. Parent ScrollView (in blocker.tsx) handles scroll —
|
||||
// KEIN nested ScrollView hier, sonst collabiert der Layout-Pass weil ScrollView
|
||||
// KEIN nested ScrollView hier, sonst kollabiert der Layout-Pass weil ScrollView
|
||||
// inner-content-view keine definierte Width für %-basierte Tile-Widths hat.
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', rowGap: 14 }}>
|
||||
@ -281,21 +193,17 @@ function DomainTile({
|
||||
|
||||
const isLegend = tier.plan === 'legend';
|
||||
|
||||
// statusColor wird auf Badge + Button angewendet.
|
||||
// iOS-native: blue (active), orange (submitted), red (rejected).
|
||||
const statusColor = (() => {
|
||||
switch (domain.status) {
|
||||
case 'submitted':
|
||||
return '#f59e0b'; // orange (Voting/Prüfung)
|
||||
return '#f59e0b';
|
||||
case 'rejected':
|
||||
return '#FF3B30'; // iOS-red
|
||||
return '#FF3B30';
|
||||
default:
|
||||
return '#007AFF'; // iOS-blue (active, "freigeben"-CTA)
|
||||
return '#007AFF';
|
||||
}
|
||||
})();
|
||||
|
||||
// Time-Color: nur Status die Aufmerksamkeit brauchen (submitted/rejected) sind farbig.
|
||||
// Active = neutral gray (settled state, kein Alarm-Indikator nötig).
|
||||
const timeColor = domain.status === 'active' ? '#a3a3a3' : statusColor;
|
||||
|
||||
const badgeLabel = (() => {
|
||||
@ -309,8 +217,6 @@ function DomainTile({
|
||||
}
|
||||
})();
|
||||
|
||||
// Tier-aware Confirm-Dialog vor Freigabe — Pro geht zu Community-Voting,
|
||||
// Legend direkt zum ReBreak-Team. Animiertes Modal statt nativem Alert.
|
||||
const isResubmit = domain.status === 'rejected';
|
||||
const confirmTitle = isLegend
|
||||
? isResubmit
|
||||
@ -363,15 +269,12 @@ function DomainTile({
|
||||
borderColor: colors.border,
|
||||
borderRadius: 14,
|
||||
padding: 8,
|
||||
// KEIN aspectRatio:1 mehr — der hat den Button auf 0 Höhe gepresst.
|
||||
// minHeight statt fixer aspect-ratio: Tile darf wachsen wenn Button da ist,
|
||||
// bleibt aber konsistent groß für visuelle Stabilität.
|
||||
minHeight: 130,
|
||||
opacity: isFreeAndUsed ? 0.55 : 1,
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{/* Top-Row: Zeit links · Badge rechts — beide in Status-Color (matcht Bottom-Button). */}
|
||||
{/* Top-Row: Zeit links · Badge rechts */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
@ -400,7 +303,7 @@ function DomainTile({
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Mitte: Icon + Domain-Name (zentriert, flex-1) */}
|
||||
{/* Mitte: Icon + Domain-Name */}
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 8 }}>
|
||||
{domain.type === 'mail_domain' || domain.type === 'mail_display_name' ? (
|
||||
<View
|
||||
@ -451,8 +354,7 @@ function DomainTile({
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Bottom-Slot: ALWAYS rendered Container (32px), Inhalt je nach Status.
|
||||
* Garantiert konsistente Tile-Höhe + sichtbaren Button. */}
|
||||
{/* Bottom-Slot: ALWAYS rendered Container (28px), Inhalt je nach Status. */}
|
||||
<View style={{ height: 28 }}>
|
||||
{showInPruefungBtn && (
|
||||
<View
|
||||
@ -521,7 +423,6 @@ function DomainTile({
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Confirm-Modal vor Submit (statt nativer Alert.alert — selber animation-Style wie SuccessAlert) */}
|
||||
<ConfirmAlert
|
||||
visible={confirmVisible}
|
||||
title={confirmTitle}
|
||||
@ -533,7 +434,6 @@ function DomainTile({
|
||||
onCancel={() => setConfirmVisible(false)}
|
||||
/>
|
||||
|
||||
{/* Success-Alert mit animiertem Check-Icon nach erfolgreichem Submit */}
|
||||
<SuccessAlert
|
||||
visible={successVisible}
|
||||
title={successContent.title}
|
||||
|
||||
@ -182,7 +182,7 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
(d) => d.status !== 'approved' && (d.type === 'web' || !d.type),
|
||||
).length,
|
||||
mail: domains.filter(
|
||||
(d) => d.status !== 'approved' && (d.type === 'mail_domain' || d.type === 'mail_display_name'),
|
||||
(d) => d.status !== 'approved' && d.type === 'mail_domain',
|
||||
).length,
|
||||
};
|
||||
|
||||
|
||||
@ -322,17 +322,20 @@
|
||||
"add_web_label": "Domain",
|
||||
"add_web_placeholder": "z.B. casino.com",
|
||||
"add_web_help": "Diese Webseite wird auf allen geschützten Geräten blockiert.",
|
||||
"add_mail_label": "E-Mail-Absender oder Display-Name",
|
||||
"add_mail_placeholder": "z.B. only4-subscribers.com oder EXTRASPIN",
|
||||
"add_mail_help": "Mail-Adresse, Domain oder Display-Name. Wir blockieren alle Mails die diesem Muster entsprechen.",
|
||||
"add_mail_label": "E-Mail-Adresse oder Domain",
|
||||
"add_mail_placeholder": "z.B. newsletter@casino.com oder casino.com",
|
||||
"add_mail_help": "E-Mail-Adresse oder Mail-Domain. Wir blockieren alle Mails von diesem Absender.",
|
||||
"add_mail_invalid": "Bitte ein Muster eingeben.",
|
||||
"add_sheet_cta": "Hinzufügen",
|
||||
"tabs_web": "Seiten",
|
||||
"tabs_mail": "Mails",
|
||||
"section_domains": "Eigene Domains",
|
||||
"section_mails": "Eigene Mails",
|
||||
"count_label": "%{count}/%{max}",
|
||||
"error_web_limit_reached": "Du hast alle Domain-Slots aufgebraucht. Entferne eine Domain oder upgrade auf Pro/Legend.",
|
||||
"error_mail_limit_reached": "Du hast alle Mail-Slots aufgebraucht. Entferne ein Mail-Pattern oder upgrade auf Pro/Legend.",
|
||||
"empty_web": "Noch keine eigenen Domains.\nTippe + um eine hinzuzufügen.",
|
||||
"empty_mail": "Noch keine E-Mail-Patterns. Tippe + um eine Adresse oder einen Display-Namen (z.B. EXTRASPIN) zu blockieren."
|
||||
"empty_mail": "Noch keine Mail-Domains. Tippe + um eine E-Mail-Adresse oder Domain zu blockieren."
|
||||
},
|
||||
"mail": {
|
||||
"title": "Mail-Schutz",
|
||||
|
||||
@ -322,17 +322,20 @@
|
||||
"add_web_label": "Domain",
|
||||
"add_web_placeholder": "e.g. casino.com",
|
||||
"add_web_help": "This website will be blocked on all your protected devices.",
|
||||
"add_mail_label": "Email sender or display name",
|
||||
"add_mail_placeholder": "e.g. only4-subscribers.com or EXTRASPIN",
|
||||
"add_mail_help": "Email address, domain or display name. We block all emails matching this pattern.",
|
||||
"add_mail_label": "Email address or domain",
|
||||
"add_mail_placeholder": "e.g. newsletter@casino.com or casino.com",
|
||||
"add_mail_help": "Email address or mail domain. We block all emails from this sender.",
|
||||
"add_mail_invalid": "Please enter a pattern.",
|
||||
"add_sheet_cta": "Add",
|
||||
"tabs_web": "Websites",
|
||||
"tabs_mail": "Emails",
|
||||
"section_domains": "Your Domains",
|
||||
"section_mails": "Your Email Filters",
|
||||
"count_label": "%{count}/%{max}",
|
||||
"error_web_limit_reached": "You've used all your domain slots. Remove a domain or upgrade to Pro/Legend.",
|
||||
"error_mail_limit_reached": "You've used all your email slots. Remove an email pattern or upgrade to Pro/Legend.",
|
||||
"empty_web": "No custom domains yet.\nTap + to add one.",
|
||||
"empty_mail": "No email patterns yet. Tap + to block an address or display name (e.g. EXTRASPIN)."
|
||||
"empty_mail": "No mail domains yet. Tap + to block an email address or domain."
|
||||
},
|
||||
"mail": {
|
||||
"title": "Mail Shield",
|
||||
|
||||
@ -322,17 +322,20 @@
|
||||
"add_web_label": "Domaine",
|
||||
"add_web_placeholder": "ex. casino.com",
|
||||
"add_web_help": "Ce site sera bloqué sur tous vos appareils protégés.",
|
||||
"add_mail_label": "Expéditeur ou nom affiché",
|
||||
"add_mail_placeholder": "ex. only4-subscribers.com ou EXTRASPIN",
|
||||
"add_mail_help": "Adresse e-mail, domaine ou nom affiché. Nous bloquons tous les mails correspondant à ce modèle.",
|
||||
"add_mail_label": "Adresse e-mail ou domaine",
|
||||
"add_mail_placeholder": "ex. newsletter@casino.com ou casino.com",
|
||||
"add_mail_help": "Adresse e-mail ou domaine mail. Nous bloquons tous les mails de cet expéditeur.",
|
||||
"add_mail_invalid": "Veuillez saisir un modèle.",
|
||||
"add_sheet_cta": "Ajouter",
|
||||
"tabs_web": "Sites",
|
||||
"tabs_mail": "E-mails",
|
||||
"section_domains": "Mes domaines",
|
||||
"section_mails": "Mes filtres mail",
|
||||
"count_label": "%{count}/%{max}",
|
||||
"error_web_limit_reached": "Vous avez utilisé tous vos emplacements de domaines. Supprimez un domaine ou passez à Pro/Legend.",
|
||||
"error_mail_limit_reached": "Vous avez utilisé tous vos emplacements e-mail. Supprimez un modèle ou passez à Pro/Legend.",
|
||||
"empty_web": "Aucun domaine personnalisé.\nAppuyez sur + pour en ajouter un.",
|
||||
"empty_mail": "Aucun filtre e-mail. Appuyez sur + pour bloquer une adresse ou un nom affiché (ex. EXTRASPIN)."
|
||||
"empty_mail": "Aucun domaine mail. Appuyez sur + pour bloquer une adresse ou un domaine."
|
||||
},
|
||||
"mail": {
|
||||
"title": "Protection Mail",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user