import { useCallback, useEffect, useState } from 'react'; import { apiFetch } from '../lib/api'; import { resolveVipCountry } from './useWebContentDomains'; export type DomainStatus = 'active' | 'submitted' | 'approved' | 'rejected'; export type EntryKind = 'web' | 'mail_domain' | 'mail_display_name'; export type CustomDomain = { id: string; domain: string; type?: EntryKind; status: DomainStatus; 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'; /** * Ergebnis von addDomain. Neben `ok` transportiert es die 3-Fall-Logik des * Backends für Web-Domains gegen Layer 1 (global) + Layer 2 (kuratierte VIP): * alreadyGlobal — Mail-Pattern schon global → kein Slot verbrannt * alreadyProtected — Web-Domain in global UND kuratierter VIP → nichts zu tun * inGlobalNotVip — Web-Domain in global, NICHT in kuratierter VIP → * User kann sie per addToVip-Re-Request zur VIP nehmen * addedToVip — Domain wurde als VIP-Zweitschutz ('approved') gespeichert */ export type AddDomainResult = { ok: boolean; error?: string; alreadyGlobal?: boolean; alreadyProtected?: boolean; inGlobalNotVip?: boolean; addedToVip?: boolean; vipFull?: boolean; newDomainId?: string; }; export type Tier = { plan: Plan; domainLimit: number; // pro=10, legend=20 (web + mail gemeinsam) refillEnabled: boolean; // pro/legend=true globalBlocklist: boolean; // pro/legend=true canSubmit: boolean; // pro/legend=true usedSlots: number; // active+submitted (NICHT approved/rejected) atLimit: boolean; }; function deriveTier(plan: Plan, domains: CustomDomain[]): Tier { // Slots: EIN gemeinsamer Pool für web + mail. Free-Tier ist entfallen. const limit = plan === 'legend' ? 20 : 10; const refill = plan !== 'free'; const usedSlots = domains.filter((d) => d.status === 'active' || d.status === 'submitted').length; return { plan, domainLimit: limit, refillEnabled: refill, globalBlocklist: refill, canSubmit: refill, usedSlots, atLimit: usedSlots >= limit, }; } export type UseCustomDomainsReturn = { domains: CustomDomain[]; tier: Tier; /** Belegte Slots (active + submitted) — web + mail gemeinsamer Pool. */ count: number; /** Slot-Limit gesamt (Pro 10 / Legend 20). */ limit: number; loading: boolean; error: string | null; refresh: () => Promise; addDomain: ( pattern: string, kind?: 'web' | 'mail', opts?: { addToVip?: boolean }, ) => 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. */ normalizeDomain: (s: string) => string; }; const DOMAIN_REGEX = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i; export function normalizeDomain(input: string): string { let s = input.trim().toLowerCase(); if (s.startsWith('https://')) s = s.slice(8); else if (s.startsWith('http://')) s = s.slice(7); const slash = s.indexOf('/'); if (slash >= 0) s = s.slice(0, slash); if (s.startsWith('www.')) s = s.slice(4); return s; } export function isValidDomain(input: string): boolean { const n = normalizeDomain(input); if (!n || n.length > 253) return false; return DOMAIN_REGEX.test(n); } /** * Public-/Freemail-Provider — dürfen NIE als Custom-Domain (web ODER mail) * geblockt werden: icloud.com/gmail.com zu blocken würde die ganze Mail/Webmail * des Users sperren. Realer Vorfall: User kopiert eine Casino-Spam-Adresse * `xyz@icloud.com` komplett ins Feld → wir extrahieren `icloud.com`. * Spiegel-Liste im Backend: `backend/server/utils/public-email-domains.ts` * — bei Änderungen beide synchron halten. */ const PUBLIC_EMAIL_DOMAINS = new Set([ 'gmail.com', 'googlemail.com', 'icloud.com', 'me.com', 'mac.com', 'outlook.com', 'outlook.de', 'hotmail.com', 'hotmail.de', 'hotmail.co.uk', 'hotmail.fr', 'live.com', 'live.de', 'msn.com', 'yahoo.com', 'yahoo.de', 'yahoo.co.uk', 'yahoo.fr', 'ymail.com', 'rocketmail.com', 'gmx.de', 'gmx.net', 'gmx.at', 'gmx.ch', 'gmx.com', 'web.de', 'aol.com', 'aim.com', 'proton.me', 'protonmail.com', 'pm.me', 'tutanota.com', 'tutanota.de', 'tuta.io', 'posteo.de', 'posteo.net', 'mailbox.org', 'hey.com', 't-online.de', 'freenet.de', 'arcor.de', 'mail.com', 'mail.de', 'email.de', 'zoho.com', 'fastmail.com', 'fastmail.fm', 'hushmail.com', 'yandex.com', 'yandex.ru', 'mail.ru', 'laposte.net', 'orange.fr', 'free.fr', 'sfr.fr', 'wanadoo.fr', 'qq.com', '163.com', '126.com', 'naver.com', 'daum.net', ]); export function isPublicEmailDomain(domain: string): boolean { return PUBLIC_EMAIL_DOMAINS.has(domain.trim().toLowerCase()); } /** * Custom-Domain CRUD gegen `/api/custom-domains/*` mit Tier-aware Limits. * * Slot-Modell (Single-Source-of-Truth: User.plan): * Pro → 10 Slots, Refill bei approved/rejected, Submit erlaubt * Legend → 20 Slots, Refill, Submit * web + mail teilen sich EINEN gemeinsamen Slot-Pool. Free-Tier ist entfallen. */ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn { const [domains, setDomains] = useState([]); const [apiCount, setApiCount] = useState(null); const [apiLimit, setApiLimit] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const fetchDomains = useCallback(async () => { try { // Backend `GET /api/custom-domains` returns // { items: CustomDomain[], count: number, limit: number } // (count/limit = gemeinsamer web+mail-Slot-Pool). Array-Fallback deckt // eine ältere Response-Form ab, falls ein gecachter Client das hier // trifft bevor das Deploy landet. const res = await apiFetch< | CustomDomain[] | { items?: CustomDomain[]; domains?: CustomDomain[]; count?: number; limit?: number } >('/api/custom-domains'); let arr: CustomDomain[] = []; let count: number | null = null; let limit: number | null = null; if (Array.isArray(res)) { arr = res; } else if (res) { arr = (res as any).items ?? (res as any).domains ?? []; count = typeof (res as any).count === 'number' ? (res as any).count : null; limit = typeof (res as any).limit === 'number' ? (res as any).limit : null; } setDomains(arr); setApiCount(count); setApiLimit(limit); setError(null); } catch (e: any) { console.error('[useCustomDomains] fetch failed:', e?.message ?? e); setError(e?.message ?? 'unknown'); } finally { setLoading(false); } }, []); useEffect(() => { fetchDomains(); }, [fetchDomains]); const addDomain = useCallback( async ( input: string, kind?: 'web' | 'mail', opts?: { addToVip?: boolean }, ): Promise => { const resolvedKind: 'web' | 'mail' = kind ?? (input.includes('@') ? 'mail' : 'web'); if (resolvedKind === 'web' && !isValidDomain(input)) return { ok: false, error: 'invalid_domain' }; if (resolvedKind === 'mail' && !input.trim()) return { ok: false, error: 'invalid_pattern' }; // Slot-Limit-Vorabcheck gegen den Backend-count/limit (Single Source of // Truth — EIN gemeinsamer Pool). Wenn die API noch keine count/limit // geliefert hat → skip, das Backend rejected dann mit LIMIT_REACHED. // Entfällt bei addToVip: 'approved'-Einträge belegen keinen Slot. if (!opts?.addToVip && apiCount != null && apiLimit != null && apiCount >= apiLimit) { return { ok: false, error: 'limit_reached' }; } const pattern = resolvedKind === 'web' ? normalizeDomain(input) : input.trim(); // Public-/Freemail-Domain (icloud.com, gmail.com …) hart ablehnen — web UND // mail. Sonst würde das Blocken die gesamte Mail/Webmail des Users sperren. const domainToCheck = resolvedKind === 'mail' && pattern.includes('@') ? pattern.slice(pattern.lastIndexOf('@') + 1) : pattern; if (isPublicEmailDomain(domainToCheck)) return { ok: false, error: 'public_domain' }; const body: Record = { pattern }; if (kind !== undefined) body.kind = kind; // Land mitschicken — Backend prüft die kuratierte VIP-Liste des Landes. if (resolvedKind === 'web') body.country = resolveVipCountry(); if (opts?.addToVip) body.addToVip = true; try { const res = await apiFetch('/api/custom-domains', { method: 'POST', body, }); if (res?.alreadyGlobal) return { ok: false, alreadyGlobal: true }; 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' }; } }, [apiCount, apiLimit, fetchDomains], ); const submitDomain = useCallback( async (id: string) => { const tier = deriveTier(plan, domains); if (!tier.canSubmit) return { ok: false, error: 'plan_does_not_support_submit' }; try { await apiFetch(`/api/custom-domains/${id}/submit`, { method: 'POST', body: {} }); await fetchDomains(); return { ok: true }; } catch (e: any) { return { ok: false, error: e?.message ?? 'submit_failed' }; } }, [plan, domains, fetchDomains], ); const removeDomain = useCallback( async (id: string) => { try { await apiFetch(`/api/custom-domains/${id}`, { method: 'DELETE' }); await fetchDomains(); return { ok: true }; } catch (e: any) { return { ok: false, error: e?.message ?? 'remove_failed' }; } }, [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 // Fallback, damit die UI auch bei einem stale-bundle-Moment funktioniert. const count: number = apiCount ?? domains.filter((d) => d.status === 'active' || d.status === 'submitted').length; const limit: number = apiLimit ?? (plan === 'legend' ? 20 : 10); return { domains, tier, count, limit, loading, error, refresh: fetchDomains, addDomain, submitDomain, removeDomain, submitVipSwap, isValidDomain, normalizeDomain, }; }