feat(vip): VIP-Slot-Replace Frontend — Swap-Dialog + Cooldown-Badge
- useCustomDomains: CustomDomain um vipDeferUntil/vipEvictAt, AddDomainResult um vipFull/newDomainId; addDomain liefert vipFull durch; submitVipSwap() - VipSwapSheet (neu): Dialog wenn VIP voll — User wählt eine eigene Domain, die in 24h ersetzt wird - VipDomainList: Badge „wird in Xh ersetzt" auf der ersetzten Kachel - blocker.tsx: vipFull → AddDomainSheet zu, VipSwapSheet auf Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
93eb3aceec
commit
bee1d9900a
@ -8,6 +8,7 @@ import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard';
|
||||
import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard';
|
||||
import { CooldownBanner } from '../../components/blocker/CooldownBanner';
|
||||
import { AddDomainSheet } from '../../components/blocker/AddDomainSheet';
|
||||
import { VipSwapSheet } from '../../components/blocker/VipSwapSheet';
|
||||
import { MyFiltersList, VipDomainList } from '../../components/blocker/VipDomainList';
|
||||
import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet';
|
||||
import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet';
|
||||
@ -47,6 +48,7 @@ export default function BlockerScreen() {
|
||||
limit: domainLimit,
|
||||
addDomain,
|
||||
submitDomain,
|
||||
submitVipSwap,
|
||||
refresh: refreshDomains,
|
||||
} = useCustomDomains(plan);
|
||||
const { sync: syncBlocklist, syncWebContent } = useBlocklistSync();
|
||||
@ -64,6 +66,8 @@ export default function BlockerScreen() {
|
||||
|
||||
const [vipOpen, setVipOpen] = useState(false);
|
||||
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||
const [vipSwapOpen, setVipSwapOpen] = useState(false);
|
||||
const [pendingNewDomainId, setPendingNewDomainId] = useState<string>('');
|
||||
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [explainerOpen, setExplainerOpen] = useState(false);
|
||||
@ -355,6 +359,28 @@ export default function BlockerScreen() {
|
||||
syncWebContent();
|
||||
const sync = await syncBlocklist();
|
||||
if (sync.ok) refresh();
|
||||
if (result.vipFull && result.newDomainId) {
|
||||
setAddSheetOpen(false);
|
||||
setPendingNewDomainId(result.newDomainId);
|
||||
setVipSwapOpen(true);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}}
|
||||
/>
|
||||
|
||||
<VipSwapSheet
|
||||
visible={vipSwapOpen}
|
||||
newDomainId={pendingNewDomainId}
|
||||
candidates={domains}
|
||||
onClose={() => {
|
||||
setVipSwapOpen(false);
|
||||
setPendingNewDomainId('');
|
||||
}}
|
||||
onSwap={async (newId, evictedId) => {
|
||||
const result = await submitVipSwap(newId, evictedId);
|
||||
if (result.ok) {
|
||||
syncWebContent();
|
||||
}
|
||||
return result;
|
||||
}}
|
||||
|
||||
@ -9,6 +9,8 @@ import { SuccessAlert } from '../SuccessAlert';
|
||||
import { useWebContentDomains } from '../../hooks/useWebContentDomains';
|
||||
import type { CustomDomain, DomainStatus, Tier } from '../../hooks/useCustomDomains';
|
||||
|
||||
type VipCustomMeta = { status: DomainStatus; vipEvictAt: string | null | undefined };
|
||||
|
||||
// ─── Meine Filter (unified web + mail_domain) ─────────────────────────────────
|
||||
|
||||
type MyFiltersProps = {
|
||||
@ -451,8 +453,10 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps)
|
||||
[domains],
|
||||
);
|
||||
const customStatusMap = useMemo(() => {
|
||||
const m = new Map<string, DomainStatus>();
|
||||
for (const d of webCustoms) m.set(d.domain.replace(/^www\./, ''), d.status);
|
||||
const m = new Map<string, VipCustomMeta>();
|
||||
for (const d of webCustoms) {
|
||||
m.set(d.domain.replace(/^www\./, ''), { status: d.status, vipEvictAt: d.vipEvictAt });
|
||||
}
|
||||
return m;
|
||||
}, [webCustoms]);
|
||||
|
||||
@ -474,6 +478,10 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps)
|
||||
const customDomains = list.filter((d) => customStatusMap.has(d));
|
||||
const curatedDomains = list.filter((d) => !customStatusMap.has(d));
|
||||
|
||||
function getMeta(d: string): VipCustomMeta {
|
||||
return customStatusMap.get(d) ?? { status: 'active', vipEvictAt: null };
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@ -531,14 +539,18 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps)
|
||||
count={t('blocker.vip_section_custom_count', { count: customDomains.length })}
|
||||
colors={colors}
|
||||
>
|
||||
{customDomains.map((d) => (
|
||||
<VipCustomTile
|
||||
key={d}
|
||||
domain={d}
|
||||
status={customStatusMap.get(d)!}
|
||||
colors={colors}
|
||||
/>
|
||||
))}
|
||||
{customDomains.map((d) => {
|
||||
const meta = getMeta(d);
|
||||
return (
|
||||
<VipCustomTile
|
||||
key={d}
|
||||
domain={d}
|
||||
status={meta.status}
|
||||
vipEvictAt={meta.vipEvictAt}
|
||||
colors={colors}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VipSubSection>
|
||||
)}
|
||||
|
||||
@ -606,16 +618,25 @@ function VipSubSection({
|
||||
function VipCustomTile({
|
||||
domain,
|
||||
status,
|
||||
vipEvictAt,
|
||||
colors,
|
||||
}: {
|
||||
domain: string;
|
||||
status: DomainStatus;
|
||||
vipEvictAt?: string | null;
|
||||
colors: ColorScheme;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const stripped = domain.replace(/^www\./, '');
|
||||
|
||||
const evictBadgeHours: number | null = (() => {
|
||||
if (!vipEvictAt) return null;
|
||||
const ms = new Date(vipEvictAt).getTime() - Date.now();
|
||||
if (ms <= 0) return null;
|
||||
return Math.ceil(ms / (1000 * 60 * 60));
|
||||
})();
|
||||
|
||||
const statusColor: string = (() => {
|
||||
switch (status) {
|
||||
case 'submitted': return colors.warning;
|
||||
@ -697,6 +718,25 @@ function VipCustomTile({
|
||||
{stripped}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{evictBadgeHours !== null && (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#fef3c7',
|
||||
borderRadius: 6,
|
||||
paddingVertical: 2,
|
||||
paddingHorizontal: 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 7, fontFamily: 'Nunito_600SemiBold', color: '#92400e' }}
|
||||
>
|
||||
{t('blocker.vip_evict_badge', { hours: evictBadgeHours })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
292
apps/rebreak-native/components/blocker/VipSwapSheet.tsx
Normal file
292
apps/rebreak-native/components/blocker/VipSwapSheet.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
import { useState } from 'react';
|
||||
import { ActivityIndicator, ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FormSheet } from '../FormSheet';
|
||||
import { useColors } from '../../lib/theme';
|
||||
import type { CustomDomain } from '../../hooks/useCustomDomains';
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
newDomainId: string;
|
||||
candidates: CustomDomain[];
|
||||
onClose: () => void;
|
||||
onSwap: (newDomainId: string, evictedDomainId: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
};
|
||||
|
||||
function isVipEligible(d: CustomDomain): boolean {
|
||||
if (d.type !== 'web') return false;
|
||||
if (d.status === 'rejected') return false;
|
||||
const now = Date.now();
|
||||
if (d.vipDeferUntil && new Date(d.vipDeferUntil).getTime() > now) return false;
|
||||
if (d.vipEvictAt && new Date(d.vipEvictAt).getTime() < now) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function VipSwapSheet({ visible, newDomainId, candidates, onClose, onSwap }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const eligible = candidates.filter(isVipEligible);
|
||||
|
||||
function close() {
|
||||
setSelectedId(null);
|
||||
setError(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleSwap() {
|
||||
if (!selectedId || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const result = await onSwap(newDomainId, selectedId);
|
||||
setSubmitting(false);
|
||||
if (result.ok) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
setError(t('blocker.vip_swap_error'));
|
||||
}
|
||||
|
||||
const ctaEnabled = selectedId !== null && !submitting;
|
||||
|
||||
return (
|
||||
<FormSheet
|
||||
visible={visible}
|
||||
onClose={close}
|
||||
title={t('blocker.vip_swap_title')}
|
||||
>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ padding: 16, gap: 12 }}
|
||||
>
|
||||
{/* Erklärtext */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
padding: 12,
|
||||
backgroundColor: '#fff7ed',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#fed7aa',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="swap-horizontal" size={18} color="#c2410c" style={{ marginTop: 1 }} />
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: '#7c2d12',
|
||||
lineHeight: 18,
|
||||
}}
|
||||
>
|
||||
{t('blocker.vip_swap_desc')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Pick-Label */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
color: colors.text,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{t('blocker.vip_swap_pick')}
|
||||
</Text>
|
||||
|
||||
{/* Domain-Liste */}
|
||||
{eligible.length === 0 ? (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: colors.textMuted,
|
||||
textAlign: 'center',
|
||||
paddingVertical: 16,
|
||||
}}
|
||||
>
|
||||
{t('blocker.vip_swap_no_candidates')}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={{ gap: 8 }}>
|
||||
{eligible.map((d) => (
|
||||
<SwapCandidateTile
|
||||
key={d.id}
|
||||
domain={d}
|
||||
selected={selectedId === d.id}
|
||||
onSelect={() => setSelectedId(d.id)}
|
||||
colors={colors}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: '#dc2626',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<View style={{ flexDirection: 'row', gap: 10, marginTop: 4 }}>
|
||||
<TouchableOpacity onPress={close} activeOpacity={0.8} style={{ flex: 1 }}>
|
||||
<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>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleSwap}
|
||||
disabled={!ctaEnabled}
|
||||
activeOpacity={0.85}
|
||||
style={{ flex: 2 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: ctaEnabled ? '#c2410c' : '#d4d4d4',
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{t('blocker.vip_swap_cta')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</FormSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function SwapCandidateTile({
|
||||
domain,
|
||||
selected,
|
||||
onSelect,
|
||||
colors,
|
||||
}: {
|
||||
domain: CustomDomain;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
colors: ReturnType<typeof useColors>;
|
||||
}) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const stripped = domain.domain.replace(/^www\./, '');
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onSelect}
|
||||
activeOpacity={0.75}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1.5,
|
||||
borderColor: selected ? '#c2410c' : colors.border,
|
||||
backgroundColor: selected ? '#fff7ed' : colors.surface,
|
||||
}}
|
||||
>
|
||||
{!imgError ? (
|
||||
<Image
|
||||
source={{ uri: `https://www.google.com/s2/favicons?domain=${stripped}&sz=64` }}
|
||||
style={{ width: 28, height: 28, borderRadius: 6 }}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
color: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{stripped.slice(0, 2).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: colors.text,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{stripped}
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
borderWidth: 1.5,
|
||||
borderColor: selected ? '#c2410c' : colors.border,
|
||||
backgroundColor: selected ? '#c2410c' : 'transparent',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{selected && <Ionicons name="checkmark" size={13} color="#fff" />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
@ -14,6 +14,8 @@ export type CustomDomain = {
|
||||
addedAt?: string;
|
||||
postId?: string | null;
|
||||
submission?: { id: string; yesVotes: number; noVotes: number; status: string } | null;
|
||||
vipDeferUntil?: string | null;
|
||||
vipEvictAt?: string | null;
|
||||
};
|
||||
|
||||
export type Plan = 'free' | 'pro' | 'legend';
|
||||
@ -34,6 +36,8 @@ export type AddDomainResult = {
|
||||
alreadyProtected?: boolean;
|
||||
inGlobalNotVip?: boolean;
|
||||
addedToVip?: boolean;
|
||||
vipFull?: boolean;
|
||||
newDomainId?: string;
|
||||
};
|
||||
|
||||
export type Tier = {
|
||||
@ -79,6 +83,7 @@ export type UseCustomDomainsReturn = {
|
||||
) => Promise<AddDomainResult>;
|
||||
submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
removeDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
submitVipSwap: (newDomainId: string, evictedDomainId: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
/** Live-Validate (regex) ob string gültiger Domain-Name ist. */
|
||||
isValidDomain: (s: string) => boolean;
|
||||
/** Normalize: lowercase, http(s)://, /path stripping, www. weg. */
|
||||
@ -186,6 +191,7 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
if (res?.alreadyProtected) return { ok: false, alreadyProtected: true };
|
||||
if (res?.inGlobalNotVip) return { ok: false, inGlobalNotVip: true };
|
||||
await fetchDomains();
|
||||
if (res?.vipFull) return { ok: true, vipFull: true, newDomainId: res.id };
|
||||
return { ok: true, addedToVip: res?.addedToVip === true };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? 'add_failed' };
|
||||
@ -222,6 +228,22 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
[fetchDomains],
|
||||
);
|
||||
|
||||
const submitVipSwap = useCallback(
|
||||
async (newDomainId: string, evictedDomainId: string) => {
|
||||
try {
|
||||
await apiFetch('/api/custom-domains/vip-swap', {
|
||||
method: 'POST',
|
||||
body: { newDomainId, evictedDomainId },
|
||||
});
|
||||
await fetchDomains();
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? 'vip_swap_failed' };
|
||||
}
|
||||
},
|
||||
[fetchDomains],
|
||||
);
|
||||
|
||||
const tier = deriveTier(plan, domains);
|
||||
|
||||
// API-Werte bevorzugen (Single Source of Truth); lokale Ableitung als
|
||||
@ -242,6 +264,7 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
addDomain,
|
||||
submitDomain,
|
||||
removeDomain,
|
||||
submitVipSwap,
|
||||
isValidDomain,
|
||||
normalizeDomain,
|
||||
};
|
||||
|
||||
@ -409,7 +409,14 @@
|
||||
"remove_domain_failed": "Entfernen fehlgeschlagen.",
|
||||
"remove_domain_actionsheet_title": "Domain wirklich entfernen?",
|
||||
"remove_domain_actionsheet_message": "%{domain} wird sofort aus deiner Sperrliste gelöscht.",
|
||||
"remove_domain_confirm_cta": "Entfernen"
|
||||
"remove_domain_confirm_cta": "Entfernen",
|
||||
"vip_swap_title": "VIP-Liste ist voll",
|
||||
"vip_swap_desc": "Deine VIP-Liste ist voll. Waehle eine Domain, die in 24 Stunden ersetzt wird — sie bleibt bis dahin geschuetzt, danach uebernimmt die neue.",
|
||||
"vip_swap_pick": "Welche Domain soll ersetzt werden?",
|
||||
"vip_swap_cta": "Austauschen",
|
||||
"vip_swap_no_candidates": "Keine tauschbaren Domains gefunden.",
|
||||
"vip_swap_error": "Austausch fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"vip_evict_badge": "wird in %{hours}h ersetzt"
|
||||
},
|
||||
"onboarding": {
|
||||
"lyra": {
|
||||
|
||||
@ -409,7 +409,14 @@
|
||||
"remove_domain_failed": "Remove failed.",
|
||||
"remove_domain_actionsheet_title": "Really remove domain?",
|
||||
"remove_domain_actionsheet_message": "%{domain} will be immediately deleted from your blocklist.",
|
||||
"remove_domain_confirm_cta": "Remove"
|
||||
"remove_domain_confirm_cta": "Remove",
|
||||
"vip_swap_title": "VIP list is full",
|
||||
"vip_swap_desc": "Your VIP list is full. Choose one of your domains to be replaced in 24 hours — it stays protected until then, after which the new domain takes over.",
|
||||
"vip_swap_pick": "Which domain should be replaced?",
|
||||
"vip_swap_cta": "Swap",
|
||||
"vip_swap_no_candidates": "No swappable domains found.",
|
||||
"vip_swap_error": "Swap failed. Please try again.",
|
||||
"vip_evict_badge": "replaced in %{hours}h"
|
||||
},
|
||||
"onboarding": {
|
||||
"lyra": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user