Web-/Mail-Limit getrennt gegen apiCounts/apiLimits geprueft (Single Source of Truth); Legacy-Response ohne counts/limits faellt auf Backend-Rejection zurueck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
244 lines
7.9 KiB
TypeScript
244 lines
7.9 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: (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<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') => {
|
|
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' };
|
|
// Per-Bucket-Limit-Check via Backend-counts/limits (Single Source of Truth).
|
|
// Wenn API noch keine counts/limits geliefert hat (Legacy-Response) → skip,
|
|
// Backend rejected dann mit WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED.
|
|
if (apiCounts && apiLimits) {
|
|
const bucket = resolvedKind;
|
|
const used = apiCounts[bucket] ?? 0;
|
|
const cap = apiLimits[bucket] ?? Infinity;
|
|
if (used >= cap) {
|
|
return {
|
|
ok: false,
|
|
error: bucket === 'mail' ? 'mail_limit_reached' : 'web_limit_reached',
|
|
};
|
|
}
|
|
}
|
|
const pattern = resolvedKind === 'web' ? normalizeDomain(input) : input.trim();
|
|
const body: Record<string, string> = { pattern };
|
|
if (kind !== undefined) body.kind = kind;
|
|
try {
|
|
const res = await apiFetch<any>('/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,
|
|
};
|
|
}
|