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:
chahinebrini 2026-05-16 02:19:27 +02:00
parent c1250836a3
commit f4da81f551
7 changed files with 323 additions and 323 deletions

View File

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

View File

@ -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

View File

@ -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}

View File

@ -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,
};

View File

@ -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",

View File

@ -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",

View File

@ -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",