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 { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard';
|
||||||
import { CooldownBanner } from '../../components/blocker/CooldownBanner';
|
import { CooldownBanner } from '../../components/blocker/CooldownBanner';
|
||||||
import { AddDomainSheet } from '../../components/blocker/AddDomainSheet';
|
import { AddDomainSheet } from '../../components/blocker/AddDomainSheet';
|
||||||
|
import { VipSwapSheet } from '../../components/blocker/VipSwapSheet';
|
||||||
import { MyFiltersList, VipDomainList } from '../../components/blocker/VipDomainList';
|
import { MyFiltersList, VipDomainList } from '../../components/blocker/VipDomainList';
|
||||||
import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet';
|
import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet';
|
||||||
import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet';
|
import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet';
|
||||||
@ -47,6 +48,7 @@ export default function BlockerScreen() {
|
|||||||
limit: domainLimit,
|
limit: domainLimit,
|
||||||
addDomain,
|
addDomain,
|
||||||
submitDomain,
|
submitDomain,
|
||||||
|
submitVipSwap,
|
||||||
refresh: refreshDomains,
|
refresh: refreshDomains,
|
||||||
} = useCustomDomains(plan);
|
} = useCustomDomains(plan);
|
||||||
const { sync: syncBlocklist, syncWebContent } = useBlocklistSync();
|
const { sync: syncBlocklist, syncWebContent } = useBlocklistSync();
|
||||||
@ -64,6 +66,8 @@ export default function BlockerScreen() {
|
|||||||
|
|
||||||
const [vipOpen, setVipOpen] = useState(false);
|
const [vipOpen, setVipOpen] = useState(false);
|
||||||
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||||
|
const [vipSwapOpen, setVipSwapOpen] = useState(false);
|
||||||
|
const [pendingNewDomainId, setPendingNewDomainId] = useState<string>('');
|
||||||
|
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
const [explainerOpen, setExplainerOpen] = useState(false);
|
const [explainerOpen, setExplainerOpen] = useState(false);
|
||||||
@ -355,6 +359,28 @@ export default function BlockerScreen() {
|
|||||||
syncWebContent();
|
syncWebContent();
|
||||||
const sync = await syncBlocklist();
|
const sync = await syncBlocklist();
|
||||||
if (sync.ok) refresh();
|
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;
|
return result;
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import { SuccessAlert } from '../SuccessAlert';
|
|||||||
import { useWebContentDomains } from '../../hooks/useWebContentDomains';
|
import { useWebContentDomains } from '../../hooks/useWebContentDomains';
|
||||||
import type { CustomDomain, DomainStatus, Tier } from '../../hooks/useCustomDomains';
|
import type { CustomDomain, DomainStatus, Tier } from '../../hooks/useCustomDomains';
|
||||||
|
|
||||||
|
type VipCustomMeta = { status: DomainStatus; vipEvictAt: string | null | undefined };
|
||||||
|
|
||||||
// ─── Meine Filter (unified web + mail_domain) ─────────────────────────────────
|
// ─── Meine Filter (unified web + mail_domain) ─────────────────────────────────
|
||||||
|
|
||||||
type MyFiltersProps = {
|
type MyFiltersProps = {
|
||||||
@ -451,8 +453,10 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps)
|
|||||||
[domains],
|
[domains],
|
||||||
);
|
);
|
||||||
const customStatusMap = useMemo(() => {
|
const customStatusMap = useMemo(() => {
|
||||||
const m = new Map<string, DomainStatus>();
|
const m = new Map<string, VipCustomMeta>();
|
||||||
for (const d of webCustoms) m.set(d.domain.replace(/^www\./, ''), d.status);
|
for (const d of webCustoms) {
|
||||||
|
m.set(d.domain.replace(/^www\./, ''), { status: d.status, vipEvictAt: d.vipEvictAt });
|
||||||
|
}
|
||||||
return m;
|
return m;
|
||||||
}, [webCustoms]);
|
}, [webCustoms]);
|
||||||
|
|
||||||
@ -474,6 +478,10 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps)
|
|||||||
const customDomains = list.filter((d) => customStatusMap.has(d));
|
const customDomains = list.filter((d) => customStatusMap.has(d));
|
||||||
const curatedDomains = 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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -531,14 +539,18 @@ export function VipDomainList({ domains, open, onToggle, colors }: VipListProps)
|
|||||||
count={t('blocker.vip_section_custom_count', { count: customDomains.length })}
|
count={t('blocker.vip_section_custom_count', { count: customDomains.length })}
|
||||||
colors={colors}
|
colors={colors}
|
||||||
>
|
>
|
||||||
{customDomains.map((d) => (
|
{customDomains.map((d) => {
|
||||||
<VipCustomTile
|
const meta = getMeta(d);
|
||||||
key={d}
|
return (
|
||||||
domain={d}
|
<VipCustomTile
|
||||||
status={customStatusMap.get(d)!}
|
key={d}
|
||||||
colors={colors}
|
domain={d}
|
||||||
/>
|
status={meta.status}
|
||||||
))}
|
vipEvictAt={meta.vipEvictAt}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</VipSubSection>
|
</VipSubSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -606,16 +618,25 @@ function VipSubSection({
|
|||||||
function VipCustomTile({
|
function VipCustomTile({
|
||||||
domain,
|
domain,
|
||||||
status,
|
status,
|
||||||
|
vipEvictAt,
|
||||||
colors,
|
colors,
|
||||||
}: {
|
}: {
|
||||||
domain: string;
|
domain: string;
|
||||||
status: DomainStatus;
|
status: DomainStatus;
|
||||||
|
vipEvictAt?: string | null;
|
||||||
colors: ColorScheme;
|
colors: ColorScheme;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const stripped = domain.replace(/^www\./, '');
|
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 = (() => {
|
const statusColor: string = (() => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'submitted': return colors.warning;
|
case 'submitted': return colors.warning;
|
||||||
@ -697,6 +718,25 @@ function VipCustomTile({
|
|||||||
{stripped}
|
{stripped}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
</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;
|
addedAt?: string;
|
||||||
postId?: string | null;
|
postId?: string | null;
|
||||||
submission?: { id: string; yesVotes: number; noVotes: number; status: string } | null;
|
submission?: { id: string; yesVotes: number; noVotes: number; status: string } | null;
|
||||||
|
vipDeferUntil?: string | null;
|
||||||
|
vipEvictAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Plan = 'free' | 'pro' | 'legend';
|
export type Plan = 'free' | 'pro' | 'legend';
|
||||||
@ -34,6 +36,8 @@ export type AddDomainResult = {
|
|||||||
alreadyProtected?: boolean;
|
alreadyProtected?: boolean;
|
||||||
inGlobalNotVip?: boolean;
|
inGlobalNotVip?: boolean;
|
||||||
addedToVip?: boolean;
|
addedToVip?: boolean;
|
||||||
|
vipFull?: boolean;
|
||||||
|
newDomainId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Tier = {
|
export type Tier = {
|
||||||
@ -79,6 +83,7 @@ export type UseCustomDomainsReturn = {
|
|||||||
) => Promise<AddDomainResult>;
|
) => Promise<AddDomainResult>;
|
||||||
submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
|
submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
|
||||||
removeDomain: (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. */
|
/** Live-Validate (regex) ob string gültiger Domain-Name ist. */
|
||||||
isValidDomain: (s: string) => boolean;
|
isValidDomain: (s: string) => boolean;
|
||||||
/** Normalize: lowercase, http(s)://, /path stripping, www. weg. */
|
/** 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?.alreadyProtected) return { ok: false, alreadyProtected: true };
|
||||||
if (res?.inGlobalNotVip) return { ok: false, inGlobalNotVip: true };
|
if (res?.inGlobalNotVip) return { ok: false, inGlobalNotVip: true };
|
||||||
await fetchDomains();
|
await fetchDomains();
|
||||||
|
if (res?.vipFull) return { ok: true, vipFull: true, newDomainId: res.id };
|
||||||
return { ok: true, addedToVip: res?.addedToVip === true };
|
return { ok: true, addedToVip: res?.addedToVip === true };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return { ok: false, error: e?.message ?? 'add_failed' };
|
return { ok: false, error: e?.message ?? 'add_failed' };
|
||||||
@ -222,6 +228,22 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
|||||||
[fetchDomains],
|
[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);
|
const tier = deriveTier(plan, domains);
|
||||||
|
|
||||||
// API-Werte bevorzugen (Single Source of Truth); lokale Ableitung als
|
// API-Werte bevorzugen (Single Source of Truth); lokale Ableitung als
|
||||||
@ -242,6 +264,7 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
|||||||
addDomain,
|
addDomain,
|
||||||
submitDomain,
|
submitDomain,
|
||||||
removeDomain,
|
removeDomain,
|
||||||
|
submitVipSwap,
|
||||||
isValidDomain,
|
isValidDomain,
|
||||||
normalizeDomain,
|
normalizeDomain,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -409,7 +409,14 @@
|
|||||||
"remove_domain_failed": "Entfernen fehlgeschlagen.",
|
"remove_domain_failed": "Entfernen fehlgeschlagen.",
|
||||||
"remove_domain_actionsheet_title": "Domain wirklich entfernen?",
|
"remove_domain_actionsheet_title": "Domain wirklich entfernen?",
|
||||||
"remove_domain_actionsheet_message": "%{domain} wird sofort aus deiner Sperrliste gelöscht.",
|
"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": {
|
"onboarding": {
|
||||||
"lyra": {
|
"lyra": {
|
||||||
|
|||||||
@ -409,7 +409,14 @@
|
|||||||
"remove_domain_failed": "Remove failed.",
|
"remove_domain_failed": "Remove failed.",
|
||||||
"remove_domain_actionsheet_title": "Really remove domain?",
|
"remove_domain_actionsheet_title": "Really remove domain?",
|
||||||
"remove_domain_actionsheet_message": "%{domain} will be immediately deleted from your blocklist.",
|
"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": {
|
"onboarding": {
|
||||||
"lyra": {
|
"lyra": {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user