feat(native/blocker): underlined Seiten/Mails tabs + per-type counter
Top-tabs above the custom-domains grid: Seiten (web) and Mails (mail_*).
2px underline highlight in colors.brandOrange for the active tab, the
muted label otherwise — matches the community/feed tab style we already
use. Pill segmented control would have needed extra inset math for two
tabs without adding clarity.
- DomainGrid filters items by the active tab. Tab-specific empty-state
copy and icon (mail-outline for the Mails tab) so the empty Mails tab
doesn't read like a broken Web view.
- mail_display_name tiles hide the submit-to-global button entirely —
matches the v1.0 backend lock; the user can't accidentally tap into a
400 from the API.
- useCustomDomains exposes countsByType + limits. Provisional client-
side estimation until the new API response shape (extended in the
parallel backend commit f2b81ee) is wired through — same TS shape,
so dropping the estimation is a one-line swap when ready.
- AddDomainSheet picks up initialType so tapping "+" while the Mails tab
is active opens the sheet pre-selected to E-Mail. Plan-limit error
handling maps WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED to the right
per-bucket message.
i18n: tabs_web / tabs_mail / count_label / error_web_limit_reached /
error_mail_limit_reached / empty_web / empty_mail across DE/EN/FR with
%{var} placeholders.
This commit is contained in:
parent
f2b81eef54
commit
5c6fa3d45b
@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ScrollView, Text, View, Alert, ActivityIndicator } from 'react-native';
|
||||
import { 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';
|
||||
@ -42,6 +43,8 @@ export default function BlockerScreen() {
|
||||
const {
|
||||
domains,
|
||||
tier,
|
||||
countsByType,
|
||||
limits,
|
||||
addDomain,
|
||||
submitDomain,
|
||||
refresh: refreshDomains,
|
||||
@ -62,6 +65,9 @@ export default function BlockerScreen() {
|
||||
}, [refreshDomains, syncBlocklist, refresh]);
|
||||
useDomainSubmissionRealtime(onDomainChange, true);
|
||||
|
||||
// Tab-State
|
||||
const [activeTab, setActiveTab] = useState<'web' | 'mail'>('web');
|
||||
|
||||
// Sheet-States
|
||||
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
@ -367,11 +373,20 @@ 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 }}>
|
||||
<DomainGrid
|
||||
domains={domains}
|
||||
tier={tier}
|
||||
activeTab={activeTab}
|
||||
onAdd={() => setAddSheetOpen(true)}
|
||||
onSubmit={submitDomain}
|
||||
onUpgradePro={() => Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))}
|
||||
@ -383,6 +398,7 @@ export default function BlockerScreen() {
|
||||
<AddDomainSheet
|
||||
visible={addSheetOpen}
|
||||
tier={tier}
|
||||
initialType={activeTab}
|
||||
onClose={() => {
|
||||
setAddSheetOpen(false);
|
||||
refreshDomains();
|
||||
@ -417,3 +433,88 @@ export default function BlockerScreen() {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── BlockerTabBar ────────────────────────────────────────────────────────────
|
||||
|
||||
function BlockerTabBar({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
countsByType,
|
||||
limits,
|
||||
}: {
|
||||
activeTab: 'web' | 'mail';
|
||||
onTabChange: (tab: 'web' | 'mail') => void;
|
||||
countsByType: CountsByType;
|
||||
limits: LimitsByType;
|
||||
}) {
|
||||
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 },
|
||||
];
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.key;
|
||||
const atMax = tab.count >= tab.max;
|
||||
const badgeColor = atMax ? colors.error : colors.textMuted;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
onPress={() => onTabChange(tab.key)}
|
||||
activeOpacity={0.7}
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingBottom: 10,
|
||||
paddingTop: 4,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: isActive ? colors.brandOrange : 'transparent',
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontFamily: isActive ? 'Nunito_700Bold' : 'Nunito_400Regular',
|
||||
color: isActive ? colors.text : colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ import { useState, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
@ -50,6 +49,7 @@ function timeSinceSubmit(input?: string | Date): string {
|
||||
type Props = {
|
||||
domains: CustomDomain[];
|
||||
tier: Tier;
|
||||
activeTab: 'web' | 'mail';
|
||||
onAdd?: () => void;
|
||||
onSubmit?: (id: string) => Promise<{ ok: boolean }>;
|
||||
onRemove?: (id: string) => Promise<{ ok: boolean }>;
|
||||
@ -65,14 +65,19 @@ const STATUS_PRIORITY: Record<string, number> = {
|
||||
approved: 99,
|
||||
};
|
||||
|
||||
export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Props) {
|
||||
export function DomainGrid({ domains, tier, activeTab, onAdd, onSubmit, onUpgradePro }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
// Slot-relevante Domains (alles außer approved). Sortiert nach Status-Priority,
|
||||
// innerhalb gleicher Priority dann newest-first by addedAt.
|
||||
// Filter by tab, then by status (exclude approved). Sort by status-priority, then newest-first.
|
||||
const visible = useMemo(() => {
|
||||
return domains
|
||||
.filter((d) => d.status !== 'approved')
|
||||
.filter((d) => {
|
||||
if (d.status === 'approved') return false;
|
||||
if (activeTab === 'mail') {
|
||||
return d.type === 'mail_domain' || d.type === 'mail_display_name';
|
||||
}
|
||||
return d.type === 'web' || !d.type;
|
||||
})
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const pa = STATUS_PRIORITY[a.status] ?? 99;
|
||||
@ -82,7 +87,7 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
|
||||
const tb = b.addedAt ? new Date(b.addedAt).getTime() : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
}, [domains]);
|
||||
}, [domains, activeTab]);
|
||||
|
||||
return (
|
||||
<View style={{ gap: 12 }}>
|
||||
@ -94,11 +99,11 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<SlotPill tier={tier} />
|
||||
{onAdd && (
|
||||
<Pressable
|
||||
<TouchableOpacity
|
||||
onPress={tier.atLimit ? undefined : onAdd}
|
||||
accessibilityLabel={t('blocker.domain_add_a11y')}
|
||||
accessibilityState={{ disabled: tier.atLimit }}
|
||||
disabled={tier.atLimit}
|
||||
activeOpacity={0.75}
|
||||
hitSlop={8}
|
||||
>
|
||||
<View
|
||||
@ -106,7 +111,6 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
// atLimit → grau + 50% opacity (deutlich visuell disabled)
|
||||
backgroundColor: tier.atLimit ? '#a3a3a3' : '#007AFF',
|
||||
opacity: tier.atLimit ? 0.5 : 1,
|
||||
alignItems: 'center',
|
||||
@ -115,7 +119,7 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
|
||||
>
|
||||
<Ionicons name="add" size={22} color="#fff" />
|
||||
</View>
|
||||
</Pressable>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@ -179,7 +183,11 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="globe-outline" size={28} color={colors.textMuted} />
|
||||
<Ionicons
|
||||
name={activeTab === 'mail' ? 'mail-outline' : 'globe-outline'}
|
||||
size={28}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
@ -189,7 +197,7 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{t('blocker.domain_empty')}
|
||||
{activeTab === 'mail' ? t('blocker.empty_mail') : t('blocker.empty_web')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
@ -341,9 +349,10 @@ function DomainTile({
|
||||
}
|
||||
}
|
||||
|
||||
const isMailDisplayName = domain.type === 'mail_display_name';
|
||||
const isFreeAndUsed = tier.plan === 'free' && domain.status !== 'active';
|
||||
const showSubmit = tier.canSubmit && domain.status === 'active';
|
||||
const showResubmit = tier.canSubmit && domain.status === 'rejected';
|
||||
const showSubmit = tier.canSubmit && domain.status === 'active' && !isMailDisplayName;
|
||||
const showResubmit = tier.canSubmit && domain.status === 'rejected' && !isMailDisplayName;
|
||||
const showInPruefungBtn = domain.status === 'submitted';
|
||||
|
||||
return (
|
||||
@ -461,9 +470,10 @@ function DomainTile({
|
||||
</View>
|
||||
)}
|
||||
{showSubmit && (
|
||||
<Pressable
|
||||
<TouchableOpacity
|
||||
onPress={openConfirm}
|
||||
disabled={submitting}
|
||||
activeOpacity={0.75}
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: 6,
|
||||
@ -482,12 +492,13 @@ function DomainTile({
|
||||
{t('blocker.domain_btn_freigeben')}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{showResubmit && (
|
||||
<Pressable
|
||||
<TouchableOpacity
|
||||
onPress={openConfirm}
|
||||
disabled={submitting}
|
||||
activeOpacity={0.75}
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: 6,
|
||||
@ -506,7 +517,7 @@ function DomainTile({
|
||||
{t('blocker.domain_btn_erneut')}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
|
||||
@ -42,9 +42,21 @@ function deriveTier(plan: Plan, domains: CustomDomain[]): Tier {
|
||||
};
|
||||
}
|
||||
|
||||
export type CountsByType = {
|
||||
web: number;
|
||||
mail: number;
|
||||
};
|
||||
|
||||
export type LimitsByType = {
|
||||
web: number;
|
||||
mail: number;
|
||||
};
|
||||
|
||||
export type UseCustomDomainsReturn = {
|
||||
domains: CustomDomain[];
|
||||
tier: Tier;
|
||||
countsByType: CountsByType;
|
||||
limits: LimitsByType;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
@ -165,9 +177,24 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
|
||||
const tier = deriveTier(plan, domains);
|
||||
|
||||
const countsByType: CountsByType = {
|
||||
web: domains.filter(
|
||||
(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'),
|
||||
).length,
|
||||
};
|
||||
|
||||
const webLimit = plan === 'legend' ? 8 : 4;
|
||||
const mailLimit = plan === 'legend' ? 2 : 1;
|
||||
const limits: LimitsByType = { web: webLimit, mail: mailLimit };
|
||||
|
||||
return {
|
||||
domains,
|
||||
tier,
|
||||
countsByType,
|
||||
limits,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchDomains,
|
||||
|
||||
@ -325,7 +325,14 @@
|
||||
"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_invalid": "Bitte ein Muster eingeben."
|
||||
"add_mail_invalid": "Bitte ein Muster eingeben.",
|
||||
"tabs_web": "Seiten",
|
||||
"tabs_mail": "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."
|
||||
},
|
||||
"mail": {
|
||||
"title": "Mail-Schutz",
|
||||
|
||||
@ -325,7 +325,14 @@
|
||||
"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_invalid": "Please enter a pattern."
|
||||
"add_mail_invalid": "Please enter a pattern.",
|
||||
"tabs_web": "Websites",
|
||||
"tabs_mail": "Emails",
|
||||
"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)."
|
||||
},
|
||||
"mail": {
|
||||
"title": "Mail Shield",
|
||||
|
||||
@ -325,7 +325,14 @@
|
||||
"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_invalid": "Veuillez saisir un modèle."
|
||||
"add_mail_invalid": "Veuillez saisir un modèle.",
|
||||
"tabs_web": "Sites",
|
||||
"tabs_mail": "E-mails",
|
||||
"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)."
|
||||
},
|
||||
"mail": {
|
||||
"title": "Protection Mail",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user