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:
chahinebrini 2026-05-16 02:03:41 +02:00
parent f2b81eef54
commit 5c6fa3d45b
6 changed files with 182 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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