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 { 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 { useRouter } from 'expo-router';
|
||||||
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
|
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import type { CountsByType, LimitsByType } from '../../hooks/useCustomDomains';
|
|
||||||
import { AppHeader } from '../../components/AppHeader';
|
import { AppHeader } from '../../components/AppHeader';
|
||||||
import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard';
|
import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard';
|
||||||
import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard';
|
import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard';
|
||||||
@ -52,9 +51,6 @@ export default function BlockerScreen() {
|
|||||||
const { sync: syncBlocklist } = useBlocklistSync();
|
const { sync: syncBlocklist } = useBlocklistSync();
|
||||||
|
|
||||||
// Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen.
|
// 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 () => {
|
const onDomainChange = useCallback(async () => {
|
||||||
await refreshDomains();
|
await refreshDomains();
|
||||||
if (urlFilterActiveRef.current) {
|
if (urlFilterActiveRef.current) {
|
||||||
@ -65,28 +61,26 @@ export default function BlockerScreen() {
|
|||||||
}, [refreshDomains, syncBlocklist, refresh]);
|
}, [refreshDomains, syncBlocklist, refresh]);
|
||||||
useDomainSubmissionRealtime(onDomainChange, true);
|
useDomainSubmissionRealtime(onDomainChange, true);
|
||||||
|
|
||||||
// Tab-State
|
// Section collapse state — beide Sections starten offen
|
||||||
const [activeTab, setActiveTab] = useState<'web' | 'mail'>('web');
|
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 [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||||
|
const [addSheetKind, setAddSheetKind] = useState<'web' | 'mail'>('web');
|
||||||
|
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
const [explainerOpen, setExplainerOpen] = 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 urlFilterActive = state?.layers.urlFilter === true;
|
||||||
const familyControlsActive = state?.layers.familyControls === true;
|
const familyControlsActive = state?.layers.familyControls === true;
|
||||||
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
|
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
|
||||||
const lockedIn = appDeletionLockActive;
|
const lockedIn = appDeletionLockActive;
|
||||||
|
|
||||||
// Ref damit onDomainChange nicht neu rendert bei jedem urlFilter-Toggle
|
|
||||||
const urlFilterActiveRef = useRef(urlFilterActive);
|
const urlFilterActiveRef = useRef(urlFilterActive);
|
||||||
useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]);
|
useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]);
|
||||||
|
|
||||||
// Auto-Sync wenn URL-Filter beim Page-Mount/-Resume schon aktiv ist und
|
// Auto-Sync wenn URL-Filter beim Page-Mount/-Resume schon aktiv ist.
|
||||||
// blocklist.bin leer/stale sein könnte. Dedupe via Ref damit wir nicht
|
|
||||||
// bei jedem Re-Render neu syncen.
|
|
||||||
const syncedOnceRef = useRef(false);
|
const syncedOnceRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!urlFilterActive) return;
|
if (!urlFilterActive) return;
|
||||||
@ -94,7 +88,7 @@ export default function BlockerScreen() {
|
|||||||
syncedOnceRef.current = true;
|
syncedOnceRef.current = true;
|
||||||
syncBlocklist().then((res) => {
|
syncBlocklist().then((res) => {
|
||||||
console.log('[blocker] auto-sync on mount:', 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]);
|
}, [urlFilterActive, syncBlocklist, refresh]);
|
||||||
|
|
||||||
@ -114,12 +108,9 @@ export default function BlockerScreen() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Filter ist aktiv aber blocklist.bin ist initial leer — sofort syncen!
|
|
||||||
// Sonst zeigt iOS "Läuft" aber blockt nichts.
|
|
||||||
const sync = await syncBlocklist();
|
const sync = await syncBlocklist();
|
||||||
console.log('[blocker] post-activate sync:', sync);
|
console.log('[blocker] post-activate sync:', sync);
|
||||||
if (sync.ok) {
|
if (sync.ok) {
|
||||||
// Stats-Card neu rendern mit dem frisch geschriebenen Count
|
|
||||||
await refresh();
|
await refresh();
|
||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@ -140,10 +131,8 @@ export default function BlockerScreen() {
|
|||||||
try {
|
try {
|
||||||
const result = await activateFamilyControls();
|
const result = await activateFamilyControls();
|
||||||
console.log('[blocker] activateFamilyControls:', result);
|
console.log('[blocker] activateFamilyControls:', result);
|
||||||
// `accessibility_pending` = die a11y-Berechtigung fehlt noch und wir haben
|
// `accessibility_pending` = a11y-Berechtigung fehlt noch → System-Settings wurden
|
||||||
// grad die System-Settings geöffnet → das IST das Feedback. Kein Fehler-
|
// geöffnet. Kein Fehler-Modal (sonst Modal-Loop bei jedem Tap).
|
||||||
// Modal (sonst Modal-Loop bei jedem Tap). a11y wird nur beim ersten
|
|
||||||
// Einrichten geholt; danach ist das hier ein 1-Tap-Arm ohne Dialog.
|
|
||||||
if (!result.enabled && result.error !== 'accessibility_pending') {
|
if (!result.enabled && result.error !== 'accessibility_pending') {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t('blocker.activate_app_lock_failed_title'),
|
t('blocker.activate_app_lock_failed_title'),
|
||||||
@ -199,8 +188,6 @@ export default function BlockerScreen() {
|
|||||||
}
|
}
|
||||||
if (bypassAlertShownRef.current) return;
|
if (bypassAlertShownRef.current) return;
|
||||||
bypassAlertShownRef.current = true;
|
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(
|
Alert.alert(
|
||||||
t('blocker.protection_off_title'),
|
t('blocker.protection_off_title'),
|
||||||
t('blocker.protection_off_message'),
|
t('blocker.protection_off_message'),
|
||||||
@ -211,6 +198,11 @@ export default function BlockerScreen() {
|
|||||||
);
|
);
|
||||||
}, [state?.phase, t]);
|
}, [state?.phase, t]);
|
||||||
|
|
||||||
|
function openAddSheet(kind: 'web' | 'mail') {
|
||||||
|
setAddSheetKind(kind);
|
||||||
|
setAddSheetOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Render ──────────────────────────────────────────────────────────
|
// ─── Render ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -226,7 +218,7 @@ export default function BlockerScreen() {
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
padding: 16,
|
padding: 16,
|
||||||
paddingBottom: tabBarHeight + 80, // platz für FAB + Tab-Bar
|
paddingBottom: tabBarHeight + 80,
|
||||||
gap: 14,
|
gap: 14,
|
||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
@ -235,7 +227,6 @@ export default function BlockerScreen() {
|
|||||||
{lockedIn ? (
|
{lockedIn ? (
|
||||||
<ProtectionLockedCard state={state} onPressSettings={openDetails} />
|
<ProtectionLockedCard state={state} onPressSettings={openDetails} />
|
||||||
) : (
|
) : (
|
||||||
// FC nicht aktiv → User kann pro Layer einzeln togglen
|
|
||||||
<View style={{ gap: 10 }}>
|
<View style={{ gap: 10 }}>
|
||||||
<LayerSwitchCard
|
<LayerSwitchCard
|
||||||
icon="globe-outline"
|
icon="globe-outline"
|
||||||
@ -322,7 +313,7 @@ export default function BlockerScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CooldownBanner — nur wenn Cooldown läuft */}
|
{/* CooldownBanner */}
|
||||||
{state.cooldown.active && (
|
{state.cooldown.active && (
|
||||||
<CooldownBanner
|
<CooldownBanner
|
||||||
remainingFormatted={cooldownRemainingFormatted}
|
remainingFormatted={cooldownRemainingFormatted}
|
||||||
@ -351,7 +342,7 @@ export default function BlockerScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Über-Limit: Custom-Domain-Banner */}
|
{/* Über-Limit Banner */}
|
||||||
{tier.atLimit && tier.usedSlots > tier.domainLimit && (
|
{tier.atLimit && tier.usedSlots > tier.domainLimit && (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -373,32 +364,50 @@ export default function BlockerScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top-Tabs: Seiten / Mails */}
|
{/* Section 1: Eigene Domains */}
|
||||||
<BlockerTabBar
|
<DomainSection
|
||||||
activeTab={activeTab}
|
title={t('blocker.section_domains')}
|
||||||
onTabChange={setActiveTab}
|
count={countsByType.web}
|
||||||
countsByType={countsByType}
|
max={limits.web}
|
||||||
limits={limits}
|
open={webOpen}
|
||||||
/>
|
onToggle={() => setWebOpen((v) => !v)}
|
||||||
|
onAdd={() => openAddSheet('web')}
|
||||||
{/* Domain Grid mit inline + Button neben SlotPill */}
|
atLimit={countsByType.web >= limits.web}
|
||||||
<View style={{ marginTop: 8 }}>
|
>
|
||||||
<DomainGrid
|
<DomainGrid
|
||||||
domains={domains}
|
domains={domains}
|
||||||
tier={tier}
|
tier={tier}
|
||||||
activeTab={activeTab}
|
kind="web"
|
||||||
onAdd={() => setAddSheetOpen(true)}
|
|
||||||
onSubmit={submitDomain}
|
onSubmit={submitDomain}
|
||||||
onUpgradePro={() => Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))}
|
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>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Sheets */}
|
{/* Sheets */}
|
||||||
<AddDomainSheet
|
<AddDomainSheet
|
||||||
visible={addSheetOpen}
|
visible={addSheetOpen}
|
||||||
tier={tier}
|
tier={tier}
|
||||||
initialType={activeTab}
|
initialType={addSheetKind}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setAddSheetOpen(false);
|
setAddSheetOpen(false);
|
||||||
refreshDomains();
|
refreshDomains();
|
||||||
@ -406,9 +415,8 @@ export default function BlockerScreen() {
|
|||||||
onAdd={async (pattern, kind) => {
|
onAdd={async (pattern, kind) => {
|
||||||
const result = await addDomain(pattern, kind);
|
const result = await addDomain(pattern, kind);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
// Neue Custom-Domain → Filter muss aktualisierten Hash-Set kriegen
|
|
||||||
const sync = await syncBlocklist();
|
const sync = await syncBlocklist();
|
||||||
if (sync.ok) refresh(); // Stats-Card mit neuem Count refreshen
|
if (sync.ok) refresh();
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}}
|
}}
|
||||||
@ -434,87 +442,152 @@ export default function BlockerScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── BlockerTabBar ────────────────────────────────────────────────────────────
|
// ─── DomainSection ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function BlockerTabBar({
|
function DomainSection({
|
||||||
activeTab,
|
title,
|
||||||
onTabChange,
|
count,
|
||||||
countsByType,
|
max,
|
||||||
limits,
|
open,
|
||||||
|
onToggle,
|
||||||
|
onAdd,
|
||||||
|
atLimit,
|
||||||
|
children,
|
||||||
}: {
|
}: {
|
||||||
activeTab: 'web' | 'mail';
|
title: string;
|
||||||
onTabChange: (tab: 'web' | 'mail') => void;
|
count: number;
|
||||||
countsByType: CountsByType;
|
max: number;
|
||||||
limits: LimitsByType;
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
atLimit: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
|
||||||
const tabs: { key: 'web' | 'mail'; label: string; count: number; max: number }[] = [
|
// Animated progress bar
|
||||||
{ key: 'web', label: t('blocker.tabs_web'), count: countsByType.web, max: limits.web },
|
const fillAnim = useRef(new Animated.Value(0)).current;
|
||||||
{ key: 'mail', label: t('blocker.tabs_mail'), count: countsByType.mail, max: limits.mail },
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
backgroundColor: colors.surface,
|
||||||
borderBottomWidth: 1,
|
borderRadius: 16,
|
||||||
borderBottomColor: colors.border,
|
borderWidth: 1,
|
||||||
marginBottom: 4,
|
borderColor: colors.border,
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{/* Section Header */}
|
||||||
const isActive = activeTab === tab.key;
|
<TouchableOpacity
|
||||||
const atMax = tab.count >= tab.max;
|
onPress={onToggle}
|
||||||
const badgeColor = atMax ? colors.error : colors.textMuted;
|
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 (
|
{open && (
|
||||||
<TouchableOpacity
|
<View style={{ paddingHorizontal: 14, paddingBottom: 14, gap: 12 }}>
|
||||||
key={tab.key}
|
{/* Progressbar */}
|
||||||
onPress={() => onTabChange(tab.key)}
|
<View
|
||||||
activeOpacity={0.7}
|
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
height: 5,
|
||||||
alignItems: 'center',
|
borderRadius: 3,
|
||||||
paddingBottom: 10,
|
backgroundColor: colors.surfaceElevated,
|
||||||
paddingTop: 4,
|
overflow: 'hidden',
|
||||||
borderBottomWidth: 2,
|
|
||||||
borderBottomColor: isActive ? colors.brandOrange : 'transparent',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontFamily: isActive ? 'Nunito_700Bold' : 'Nunito_400Regular',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: isActive ? colors.text : colors.textMuted,
|
color: atLimit ? colors.textMuted : '#fff',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{t('blocker.add_domain')}
|
||||||
</Text>
|
</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>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
|
||||||
})}
|
{/* Grid */}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,9 @@ import { useState, useEffect } from 'react';
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image,
|
Image,
|
||||||
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
@ -15,7 +17,6 @@ import {
|
|||||||
} from '../../hooks/useCustomDomains';
|
} from '../../hooks/useCustomDomains';
|
||||||
import { useColors, type ColorScheme } from '../../lib/theme';
|
import { useColors, type ColorScheme } from '../../lib/theme';
|
||||||
import { FormSheet } from '../FormSheet';
|
import { FormSheet } from '../FormSheet';
|
||||||
import { SheetFieldStack } from '../SheetFieldStack';
|
|
||||||
|
|
||||||
type InputKind = 'web' | 'mail';
|
type InputKind = 'web' | 'mail';
|
||||||
|
|
||||||
@ -35,7 +36,6 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
const [confirmPermanent, setConfirmPermanent] = useState(false);
|
const [confirmPermanent, setConfirmPermanent] = useState(false);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [fieldsDone, setFieldsDone] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) setKind(initialType ?? 'web');
|
if (visible) setKind(initialType ?? 'web');
|
||||||
@ -44,17 +44,13 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
const normalizedWeb = kind === 'web' ? normalizeDomain(input) : '';
|
const normalizedWeb = kind === 'web' ? normalizeDomain(input) : '';
|
||||||
|
|
||||||
// For mail input: if the user typed a full address (local@domain.tld), strip
|
// 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
|
// the local-part and keep only the domain. A bare domain without "@" stays as-is.
|
||||||
// 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).
|
|
||||||
const mailPattern = (() => {
|
const mailPattern = (() => {
|
||||||
if (kind !== 'mail') return '';
|
if (kind !== 'mail') return '';
|
||||||
const raw = input.trim();
|
const raw = input.trim();
|
||||||
if (!raw) return '';
|
if (!raw) return '';
|
||||||
const atIdx = raw.lastIndexOf('@');
|
const atIdx = raw.lastIndexOf('@');
|
||||||
if (atIdx === -1) return raw;
|
if (atIdx === -1) return raw.toLowerCase();
|
||||||
return raw.slice(atIdx + 1).trim().toLowerCase();
|
return raw.slice(atIdx + 1).trim().toLowerCase();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@ -62,7 +58,6 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
setInput('');
|
setInput('');
|
||||||
setConfirmPermanent(false);
|
setConfirmPermanent(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
setFieldsDone(false);
|
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,7 +66,6 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
setKind(next);
|
setKind(next);
|
||||||
setInput('');
|
setInput('');
|
||||||
setError(null);
|
setError(null);
|
||||||
setFieldsDone(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInputValid(): boolean {
|
function isInputValid(): boolean {
|
||||||
@ -83,7 +77,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
if (!isInputValid() || !confirmPermanent || adding) return;
|
if (!isInputValid() || !confirmPermanent || adding) return;
|
||||||
setAdding(true);
|
setAdding(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const pattern = kind === 'web' ? input : mailPattern;
|
const pattern = kind === 'web' ? normalizeDomain(input) : mailPattern;
|
||||||
const result = await onAdd(pattern, kind);
|
const result = await onAdd(pattern, kind);
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
@ -118,39 +112,56 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
? t('blocker.add_web_help')
|
? t('blocker.add_web_help')
|
||||||
: t('blocker.add_mail_help');
|
: t('blocker.add_mail_help');
|
||||||
|
|
||||||
const validateField = kind === 'web'
|
const canSubmit = isInputValid() && confirmPermanent && !adding;
|
||||||
? (v: string) => isValidDomain(v) ? undefined : t('blocker.add_sheet_invalid')
|
|
||||||
: (v: string) => v.trim().length > 0 ? undefined : t('blocker.add_mail_invalid');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSheet
|
<FormSheet
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
title={t('blocker.add_sheet_title')}
|
title={t('blocker.add_sheet_title')}
|
||||||
initialHeightPct={0.75}
|
initialHeightPct={0.78}
|
||||||
growWithKeyboard
|
growWithKeyboard
|
||||||
>
|
>
|
||||||
<SheetFieldStack
|
<ScrollView
|
||||||
intro={
|
keyboardShouldPersistTaps="handled"
|
||||||
<TypePicker kind={kind} onChange={handleKindChange} />
|
showsVerticalScrollIndicator={false}
|
||||||
}
|
contentContainerStyle={{ padding: 16, gap: 12 }}
|
||||||
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)}
|
|
||||||
>
|
>
|
||||||
{/* 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
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -158,7 +169,6 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
padding: 12,
|
padding: 12,
|
||||||
backgroundColor: colors.surfaceElevated,
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@ -180,8 +190,8 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Favicon-Preview (nur Web) */}
|
{/* 4. Preview-Card */}
|
||||||
{kind === 'web' && (
|
{kind === 'web' ? (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -190,13 +200,10 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
padding: 12,
|
padding: 12,
|
||||||
backgroundColor: colors.surfaceElevated,
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
source={{
|
source={{ uri: `https://www.google.com/s2/favicons?domain=${normalizedWeb || 'example.com'}&sz=64` }}
|
||||||
uri: `https://www.google.com/s2/favicons?domain=${normalizedWeb}&sz=64`,
|
|
||||||
}}
|
|
||||||
style={{ width: 24, height: 24, borderRadius: 4 }}
|
style={{ width: 24, height: 24, borderRadius: 4 }}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
@ -204,17 +211,14 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: colors.text,
|
color: normalizedWeb ? colors.text : colors.textMuted,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{normalizedWeb}
|
{normalizedWeb || inputPlaceholder}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
) : (
|
||||||
|
|
||||||
{/* Mail-Typ Icon-Preview */}
|
|
||||||
{kind === 'mail' && (
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -223,7 +227,6 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
padding: 12,
|
padding: 12,
|
||||||
backgroundColor: colors.surfaceElevated,
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@ -243,7 +246,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: colors.text,
|
color: mailPattern ? colors.text : colors.textMuted,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@ -252,7 +255,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Warnung */}
|
{/* 5. Warning-Card */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -262,10 +265,9 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#fcd34d',
|
borderColor: '#fcd34d',
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="lock-closed" size={18} color="#92400e" />
|
<Ionicons name="lock-closed" size={18} color="#92400e" style={{ marginTop: 1 }} />
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -279,7 +281,7 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Confirm-Checkbox */}
|
{/* 6. Confirm-Checkbox */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => setConfirmPermanent((v) => !v)}
|
onPress={() => setConfirmPermanent((v) => !v)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
@ -288,7 +290,6 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
gap: 10,
|
gap: 10,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
marginBottom: 14,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@ -319,53 +320,70 @@ export function AddDomainSheet({ visible, tier, initialType, onClose, onAdd }: P
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{error && (
|
{/* 7. Buttons */}
|
||||||
<Text
|
<View style={{ flexDirection: 'row', gap: 10, marginTop: 4 }}>
|
||||||
style={{
|
<TouchableOpacity
|
||||||
fontSize: 13,
|
onPress={close}
|
||||||
fontFamily: 'Nunito_400Regular',
|
activeOpacity={0.8}
|
||||||
color: '#dc2626',
|
style={{ flex: 1 }}
|
||||||
marginBottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{error}
|
<View
|
||||||
</Text>
|
style={{
|
||||||
)}
|
borderRadius: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
{/* Add-Button */}
|
alignItems: 'center',
|
||||||
<TouchableOpacity
|
backgroundColor: colors.surfaceElevated,
|
||||||
onPress={handleAdd}
|
borderWidth: 1,
|
||||||
disabled={!confirmPermanent || adding}
|
borderColor: colors.border,
|
||||||
activeOpacity={0.85}
|
}}
|
||||||
style={{ marginBottom: 12 }}
|
>
|
||||||
>
|
<Text style={{ fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
|
||||||
<View
|
{t('common.cancel')}
|
||||||
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')}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
</View>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
|
||||||
</SheetFieldStack>
|
<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>
|
</FormSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── TypePicker ──────────────────────────────────────────────────────────────
|
// ─── 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 { t } = useTranslation();
|
||||||
const colors = useColors();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@ -32,27 +32,11 @@ function timeAgo(input?: string | Date): string {
|
|||||||
return `${months}mo`;
|
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 = {
|
type Props = {
|
||||||
domains: CustomDomain[];
|
domains: CustomDomain[];
|
||||||
tier: Tier;
|
tier: Tier;
|
||||||
activeTab: 'web' | 'mail';
|
kind: 'web' | 'mail';
|
||||||
onAdd?: () => void;
|
|
||||||
onSubmit?: (id: string) => Promise<{ ok: boolean }>;
|
onSubmit?: (id: string) => Promise<{ ok: boolean }>;
|
||||||
onRemove?: (id: string) => Promise<{ ok: boolean }>;
|
|
||||||
onUpgradePro?: () => void;
|
onUpgradePro?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -65,16 +49,16 @@ const STATUS_PRIORITY: Record<string, number> = {
|
|||||||
approved: 99,
|
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 { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
// Filter by tab, then by status (exclude approved). Sort by status-priority, then newest-first.
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
return domains
|
return domains
|
||||||
.filter((d) => {
|
.filter((d) => {
|
||||||
if (d.status === 'approved') return false;
|
if (d.status === 'approved') return false;
|
||||||
if (activeTab === 'mail') {
|
if (kind === 'mail') {
|
||||||
return d.type === 'mail_domain' || d.type === 'mail_display_name';
|
return d.type === 'mail_domain';
|
||||||
}
|
}
|
||||||
return d.type === 'web' || !d.type;
|
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;
|
const tb = b.addedAt ? new Date(b.addedAt).getTime() : 0;
|
||||||
return tb - ta;
|
return tb - ta;
|
||||||
});
|
});
|
||||||
}, [domains, activeTab]);
|
}, [domains, kind]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ gap: 12 }}>
|
<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) */}
|
{/* Limit-Reached Upsell (nur Free) */}
|
||||||
{tier.atLimit && tier.plan === 'free' && (
|
{tier.atLimit && tier.plan === 'free' && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@ -184,7 +118,7 @@ export function DomainGrid({ domains, tier, activeTab, onAdd, onSubmit, onUpgrad
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={activeTab === 'mail' ? 'mail-outline' : 'globe-outline'}
|
name={kind === 'mail' ? 'mail-outline' : 'globe-outline'}
|
||||||
size={28}
|
size={28}
|
||||||
color={colors.textMuted}
|
color={colors.textMuted}
|
||||||
/>
|
/>
|
||||||
@ -197,7 +131,7 @@ export function DomainGrid({ domains, tier, activeTab, onAdd, onSubmit, onUpgrad
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeTab === 'mail' ? t('blocker.empty_mail') : t('blocker.empty_web')}
|
{kind === 'mail' ? t('blocker.empty_mail') : t('blocker.empty_web')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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 ────────────────────────────────────────────────────────────────
|
// ─── Tiles ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DomainTilesGrid({
|
function DomainTilesGrid({
|
||||||
@ -245,7 +157,7 @@ function DomainTilesGrid({
|
|||||||
onSubmit?: (id: string) => Promise<{ ok: boolean }>;
|
onSubmit?: (id: string) => Promise<{ ok: boolean }>;
|
||||||
}) {
|
}) {
|
||||||
// 3-Spalten-Grid via flex-wrap. Parent ScrollView (in blocker.tsx) handles scroll —
|
// 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.
|
// inner-content-view keine definierte Width für %-basierte Tile-Widths hat.
|
||||||
return (
|
return (
|
||||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', rowGap: 14 }}>
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', rowGap: 14 }}>
|
||||||
@ -281,21 +193,17 @@ function DomainTile({
|
|||||||
|
|
||||||
const isLegend = tier.plan === 'legend';
|
const isLegend = tier.plan === 'legend';
|
||||||
|
|
||||||
// statusColor wird auf Badge + Button angewendet.
|
|
||||||
// iOS-native: blue (active), orange (submitted), red (rejected).
|
|
||||||
const statusColor = (() => {
|
const statusColor = (() => {
|
||||||
switch (domain.status) {
|
switch (domain.status) {
|
||||||
case 'submitted':
|
case 'submitted':
|
||||||
return '#f59e0b'; // orange (Voting/Prüfung)
|
return '#f59e0b';
|
||||||
case 'rejected':
|
case 'rejected':
|
||||||
return '#FF3B30'; // iOS-red
|
return '#FF3B30';
|
||||||
default:
|
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 timeColor = domain.status === 'active' ? '#a3a3a3' : statusColor;
|
||||||
|
|
||||||
const badgeLabel = (() => {
|
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 isResubmit = domain.status === 'rejected';
|
||||||
const confirmTitle = isLegend
|
const confirmTitle = isLegend
|
||||||
? isResubmit
|
? isResubmit
|
||||||
@ -363,15 +269,12 @@ function DomainTile({
|
|||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
padding: 8,
|
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,
|
minHeight: 130,
|
||||||
opacity: isFreeAndUsed ? 0.55 : 1,
|
opacity: isFreeAndUsed ? 0.55 : 1,
|
||||||
gap: 6,
|
gap: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Top-Row: Zeit links · Badge rechts — beide in Status-Color (matcht Bottom-Button). */}
|
{/* Top-Row: Zeit links · Badge rechts */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -400,7 +303,7 @@ function DomainTile({
|
|||||||
</View>
|
</View>
|
||||||
</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 }}>
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 8 }}>
|
||||||
{domain.type === 'mail_domain' || domain.type === 'mail_display_name' ? (
|
{domain.type === 'mail_domain' || domain.type === 'mail_display_name' ? (
|
||||||
<View
|
<View
|
||||||
@ -451,8 +354,7 @@ function DomainTile({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Bottom-Slot: ALWAYS rendered Container (32px), Inhalt je nach Status.
|
{/* Bottom-Slot: ALWAYS rendered Container (28px), Inhalt je nach Status. */}
|
||||||
* Garantiert konsistente Tile-Höhe + sichtbaren Button. */}
|
|
||||||
<View style={{ height: 28 }}>
|
<View style={{ height: 28 }}>
|
||||||
{showInPruefungBtn && (
|
{showInPruefungBtn && (
|
||||||
<View
|
<View
|
||||||
@ -521,7 +423,6 @@ function DomainTile({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Confirm-Modal vor Submit (statt nativer Alert.alert — selber animation-Style wie SuccessAlert) */}
|
|
||||||
<ConfirmAlert
|
<ConfirmAlert
|
||||||
visible={confirmVisible}
|
visible={confirmVisible}
|
||||||
title={confirmTitle}
|
title={confirmTitle}
|
||||||
@ -533,7 +434,6 @@ function DomainTile({
|
|||||||
onCancel={() => setConfirmVisible(false)}
|
onCancel={() => setConfirmVisible(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Success-Alert mit animiertem Check-Icon nach erfolgreichem Submit */}
|
|
||||||
<SuccessAlert
|
<SuccessAlert
|
||||||
visible={successVisible}
|
visible={successVisible}
|
||||||
title={successContent.title}
|
title={successContent.title}
|
||||||
|
|||||||
@ -182,7 +182,7 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
|||||||
(d) => d.status !== 'approved' && (d.type === 'web' || !d.type),
|
(d) => d.status !== 'approved' && (d.type === 'web' || !d.type),
|
||||||
).length,
|
).length,
|
||||||
mail: domains.filter(
|
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,
|
).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -322,17 +322,20 @@
|
|||||||
"add_web_label": "Domain",
|
"add_web_label": "Domain",
|
||||||
"add_web_placeholder": "z.B. casino.com",
|
"add_web_placeholder": "z.B. casino.com",
|
||||||
"add_web_help": "Diese Webseite wird auf allen geschützten Geräten blockiert.",
|
"add_web_help": "Diese Webseite wird auf allen geschützten Geräten blockiert.",
|
||||||
"add_mail_label": "E-Mail-Absender oder Display-Name",
|
"add_mail_label": "E-Mail-Adresse oder Domain",
|
||||||
"add_mail_placeholder": "z.B. only4-subscribers.com oder EXTRASPIN",
|
"add_mail_placeholder": "z.B. newsletter@casino.com oder casino.com",
|
||||||
"add_mail_help": "Mail-Adresse, Domain oder Display-Name. Wir blockieren alle Mails die diesem Muster entsprechen.",
|
"add_mail_help": "E-Mail-Adresse oder Mail-Domain. Wir blockieren alle Mails von diesem Absender.",
|
||||||
"add_mail_invalid": "Bitte ein Muster eingeben.",
|
"add_mail_invalid": "Bitte ein Muster eingeben.",
|
||||||
|
"add_sheet_cta": "Hinzufügen",
|
||||||
"tabs_web": "Seiten",
|
"tabs_web": "Seiten",
|
||||||
"tabs_mail": "Mails",
|
"tabs_mail": "Mails",
|
||||||
|
"section_domains": "Eigene Domains",
|
||||||
|
"section_mails": "Eigene Mails",
|
||||||
"count_label": "%{count}/%{max}",
|
"count_label": "%{count}/%{max}",
|
||||||
"error_web_limit_reached": "Du hast alle Domain-Slots aufgebraucht. Entferne eine Domain oder upgrade auf Pro/Legend.",
|
"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.",
|
"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_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": {
|
"mail": {
|
||||||
"title": "Mail-Schutz",
|
"title": "Mail-Schutz",
|
||||||
|
|||||||
@ -322,17 +322,20 @@
|
|||||||
"add_web_label": "Domain",
|
"add_web_label": "Domain",
|
||||||
"add_web_placeholder": "e.g. casino.com",
|
"add_web_placeholder": "e.g. casino.com",
|
||||||
"add_web_help": "This website will be blocked on all your protected devices.",
|
"add_web_help": "This website will be blocked on all your protected devices.",
|
||||||
"add_mail_label": "Email sender or display name",
|
"add_mail_label": "Email address or domain",
|
||||||
"add_mail_placeholder": "e.g. only4-subscribers.com or EXTRASPIN",
|
"add_mail_placeholder": "e.g. newsletter@casino.com or casino.com",
|
||||||
"add_mail_help": "Email address, domain or display name. We block all emails matching this pattern.",
|
"add_mail_help": "Email address or mail domain. We block all emails from this sender.",
|
||||||
"add_mail_invalid": "Please enter a pattern.",
|
"add_mail_invalid": "Please enter a pattern.",
|
||||||
|
"add_sheet_cta": "Add",
|
||||||
"tabs_web": "Websites",
|
"tabs_web": "Websites",
|
||||||
"tabs_mail": "Emails",
|
"tabs_mail": "Emails",
|
||||||
|
"section_domains": "Your Domains",
|
||||||
|
"section_mails": "Your Email Filters",
|
||||||
"count_label": "%{count}/%{max}",
|
"count_label": "%{count}/%{max}",
|
||||||
"error_web_limit_reached": "You've used all your domain slots. Remove a domain or upgrade to Pro/Legend.",
|
"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.",
|
"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_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": {
|
"mail": {
|
||||||
"title": "Mail Shield",
|
"title": "Mail Shield",
|
||||||
|
|||||||
@ -322,17 +322,20 @@
|
|||||||
"add_web_label": "Domaine",
|
"add_web_label": "Domaine",
|
||||||
"add_web_placeholder": "ex. casino.com",
|
"add_web_placeholder": "ex. casino.com",
|
||||||
"add_web_help": "Ce site sera bloqué sur tous vos appareils protégés.",
|
"add_web_help": "Ce site sera bloqué sur tous vos appareils protégés.",
|
||||||
"add_mail_label": "Expéditeur ou nom affiché",
|
"add_mail_label": "Adresse e-mail ou domaine",
|
||||||
"add_mail_placeholder": "ex. only4-subscribers.com ou EXTRASPIN",
|
"add_mail_placeholder": "ex. newsletter@casino.com ou casino.com",
|
||||||
"add_mail_help": "Adresse e-mail, domaine ou nom affiché. Nous bloquons tous les mails correspondant à ce modèle.",
|
"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_mail_invalid": "Veuillez saisir un modèle.",
|
||||||
|
"add_sheet_cta": "Ajouter",
|
||||||
"tabs_web": "Sites",
|
"tabs_web": "Sites",
|
||||||
"tabs_mail": "E-mails",
|
"tabs_mail": "E-mails",
|
||||||
|
"section_domains": "Mes domaines",
|
||||||
|
"section_mails": "Mes filtres mail",
|
||||||
"count_label": "%{count}/%{max}",
|
"count_label": "%{count}/%{max}",
|
||||||
"error_web_limit_reached": "Vous avez utilisé tous vos emplacements de domaines. Supprimez un domaine ou passez à Pro/Legend.",
|
"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.",
|
"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_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": {
|
"mail": {
|
||||||
"title": "Protection Mail",
|
"title": "Protection Mail",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user