import { useCallback, useEffect, useState } from 'react'; import { apiFetch } from '../lib/api'; 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; }; export type Plan = 'free' | 'pro' | 'legend'; export type Tier = { plan: Plan; domainLimit: number; // free=5, pro=5, legend=10 refillEnabled: boolean; // free=false, pro/legend=true globalBlocklist: boolean; // free=false, pro/legend=true canSubmit: boolean; // free=false, pro/legend=true usedSlots: number; // active+submitted (NICHT approved/rejected) atLimit: boolean; }; function deriveTier(plan: Plan, domains: CustomDomain[]): Tier { const limit = plan === 'legend' ? 10 : 5; 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 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; addDomain: (pattern: string, kind?: 'web' | 'mail') => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>; submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>; removeDomain: (id: 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); } /** * Custom-Domain CRUD gegen `/api/custom-domains/*` mit Tier-aware Limits. * * Tier-Logik (Single-Source-of-Truth: User.plan): * Free → 5 Slots, kein Refill, keine Submit * Pro → 5 Slots, Refill bei approved/rejected, Submit erlaubt * Legend → 10 Slots, Refill, Submit */ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn { const [domains, setDomains] = useState([]); const [apiCounts, setApiCounts] = useState(null); const [apiLimits, setApiLimits] = 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[], counts: { web, mail }, limits: { web, mail } } // since the slot-pool split (commit f2b81ee). Legacy fall-throughs cover // an older shape (bare array or { domains }) in case a cached client // ever hits this code path before the deploy lands. const res = await apiFetch< | CustomDomain[] | { domains?: CustomDomain[] } | { items?: CustomDomain[]; counts?: CountsByType; limits?: LimitsByType } >('/api/custom-domains'); let arr: CustomDomain[] = []; let counts: CountsByType | null = null; let limits: LimitsByType | null = null; if (Array.isArray(res)) { arr = res; } else if (res) { arr = (res as any).items ?? (res as any).domains ?? []; counts = (res as any).counts ?? null; limits = (res as any).limits ?? null; } setDomains(arr); setApiCounts(counts); setApiLimits(limits); 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') => { 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' }; const tier = deriveTier(plan, domains); if (tier.atLimit) return { ok: false, error: 'limit_reached' }; const pattern = resolvedKind === 'web' ? normalizeDomain(input) : input.trim(); const body: Record = { pattern }; if (kind !== undefined) body.kind = kind; try { const res = await apiFetch('/api/custom-domains', { method: 'POST', body, }); if (res?.alreadyGlobal) { return { ok: false, alreadyGlobal: true }; } await fetchDomains(); return { ok: true }; } catch (e: any) { return { ok: false, error: e?.message ?? 'add_failed' }; } }, [plan, domains, 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 tier = deriveTier(plan, domains); // Prefer API-driven counts/limits when the backend returned the new shape; // fall back to local derivation so the UI works during a stale-bundle moment. const countsByType: CountsByType = apiCounts ?? { web: domains.filter( (d) => d.status !== 'approved' && (d.type === 'web' || !d.type), ).length, mail: domains.filter( (d) => d.status !== 'approved' && d.type === 'mail_domain', ).length, }; const limits: LimitsByType = apiLimits ?? { web: plan === 'legend' ? 10 : 5, mail: plan === 'legend' ? 10 : 5, }; return { domains, tier, countsByType, limits, loading, error, refresh: fetchDomains, addDomain, submitDomain, removeDomain, isValidDomain, normalizeDomain, }; }