chahinebrini c2323c1aba fix(native): read { items, counts, limits } from custom-domains GET
The /api/custom-domains endpoint changed shape with the slot-pool split
in commit f2b81ee — it now returns { items, counts, limits } where it
used to return a bare CustomDomain[]. The hook was still matching
Array.isArray(res) or res.domains and silently fell back to an empty
list, so a successful POST went unreflected on the blocker page (user
reported "kein fehler aber domain taucht nicht in der liste" after
adding communications@only4subscribers.com).

Now reads items / counts / limits when present, prefers the API-driven
counts and limits over the client-side derivation (still kept as a
fallback for the stale-bundle window between deploys). Legacy bare-
array + { domains } shapes still resolve too in case a cached client
hits this code path before the new backend lands.
2026-05-16 02:32:21 +02:00

229 lines
7.3 KiB
TypeScript

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<void>;
addDomain: (domain: 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<CustomDomain[]>([]);
const [apiCounts, setApiCounts] = useState<CountsByType | null>(null);
const [apiLimits, setApiLimits] = useState<LimitsByType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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' = 'web') => {
if (kind === 'web' && !isValidDomain(input)) return { ok: false, error: 'invalid_domain' };
if (kind === '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 = kind === 'web' ? normalizeDomain(input) : input.trim();
try {
const res = await apiFetch<any>('/api/custom-domains', {
method: 'POST',
body: { pattern, kind },
});
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,
};
}