From bee1d9900ac08e26e890fc5a7c5c4aa8fcb23d7f Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Fri, 22 May 2026 20:07:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(vip):=20VIP-Slot-Replace=20Frontend=20?= =?UTF-8?q?=E2=80=94=20Swap-Dialog=20+=20Cooldown-Badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/rebreak-native/app/(app)/blocker.tsx | 26 ++ .../components/blocker/VipDomainList.tsx | 60 +++- .../components/blocker/VipSwapSheet.tsx | 292 ++++++++++++++++++ apps/rebreak-native/hooks/useCustomDomains.ts | 23 ++ apps/rebreak-native/locales/de.json | 9 +- apps/rebreak-native/locales/en.json | 9 +- 6 files changed, 407 insertions(+), 12 deletions(-) create mode 100644 apps/rebreak-native/components/blocker/VipSwapSheet.tsx diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx index e0a61af..6466607 100644 --- a/apps/rebreak-native/app/(app)/blocker.tsx +++ b/apps/rebreak-native/app/(app)/blocker.tsx @@ -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(''); 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; + }} + /> + + { + setVipSwapOpen(false); + setPendingNewDomainId(''); + }} + onSwap={async (newId, evictedId) => { + const result = await submitVipSwap(newId, evictedId); + if (result.ok) { + syncWebContent(); } return result; }} diff --git a/apps/rebreak-native/components/blocker/VipDomainList.tsx b/apps/rebreak-native/components/blocker/VipDomainList.tsx index ca675aa..e3d6c6c 100644 --- a/apps/rebreak-native/components/blocker/VipDomainList.tsx +++ b/apps/rebreak-native/components/blocker/VipDomainList.tsx @@ -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(); - for (const d of webCustoms) m.set(d.domain.replace(/^www\./, ''), d.status); + const m = new Map(); + 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 ( - {customDomains.map((d) => ( - - ))} + {customDomains.map((d) => { + const meta = getMeta(d); + return ( + + ); + })} )} @@ -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} + + {evictBadgeHours !== null && ( + + + {t('blocker.vip_evict_badge', { hours: evictBadgeHours })} + + + )} ); } diff --git a/apps/rebreak-native/components/blocker/VipSwapSheet.tsx b/apps/rebreak-native/components/blocker/VipSwapSheet.tsx new file mode 100644 index 0000000..a81843c --- /dev/null +++ b/apps/rebreak-native/components/blocker/VipSwapSheet.tsx @@ -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(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + {/* Erklärtext */} + + + + {t('blocker.vip_swap_desc')} + + + + {/* Pick-Label */} + + {t('blocker.vip_swap_pick')} + + + {/* Domain-Liste */} + {eligible.length === 0 ? ( + + {t('blocker.vip_swap_no_candidates')} + + ) : ( + + {eligible.map((d) => ( + setSelectedId(d.id)} + colors={colors} + /> + ))} + + )} + + {error && ( + + {error} + + )} + + {/* Buttons */} + + + + + {t('common.cancel')} + + + + + + + {submitting ? ( + + ) : ( + + {t('blocker.vip_swap_cta')} + + )} + + + + + + ); +} + +function SwapCandidateTile({ + domain, + selected, + onSelect, + colors, +}: { + domain: CustomDomain; + selected: boolean; + onSelect: () => void; + colors: ReturnType; +}) { + const [imgError, setImgError] = useState(false); + const stripped = domain.domain.replace(/^www\./, ''); + + return ( + + {!imgError ? ( + setImgError(true)} + /> + ) : ( + + + {stripped.slice(0, 2).toUpperCase()} + + + )} + + + {stripped} + + + + {selected && } + + + ); +} diff --git a/apps/rebreak-native/hooks/useCustomDomains.ts b/apps/rebreak-native/hooks/useCustomDomains.ts index f547c04..b52ee09 100644 --- a/apps/rebreak-native/hooks/useCustomDomains.ts +++ b/apps/rebreak-native/hooks/useCustomDomains.ts @@ -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; 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, }; diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 2e16fb7..5ff7aeb 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -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": { diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 82b80bf..33fe715 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -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": {