refactor(domains): gemeinsamer 10/20-Slot-Pool, Free-Tier entfernt
Custom-Domain-Slots sind jetzt EIN gemeinsamer Pool für web + mail
(Pro 10 / Legend 20) statt getrennter web/mail-Buckets. Free-Tier ist
entfallen — PLAN_LIMITS hat nur noch pro + legend, getPlanLimits
defaultet auf pro.
Backend:
- plan-features: customDomains ist eine Zahl (CustomDomainLimits weg)
- index.post: Slot-Check gegen Gesamt-Count, Fehler einheitlich LIMIT_REACHED
- index.get: liefert { items, count, limit }
- change-preview + coach/message an die neue Form angepasst
Frontend:
- useCustomDomains: count/limit (Zahlen) statt countsByType/limits
- AddDomainSheet: ein generischer Limit-Hinweis (error_limit_reached)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
7cc30db020
commit
704958320b
@ -43,8 +43,8 @@ export default function BlockerScreen() {
|
||||
const {
|
||||
domains,
|
||||
tier,
|
||||
countsByType,
|
||||
limits,
|
||||
count: domainCount,
|
||||
limit: domainLimit,
|
||||
addDomain,
|
||||
submitDomain,
|
||||
refresh: refreshDomains,
|
||||
@ -324,8 +324,8 @@ export default function BlockerScreen() {
|
||||
<MyFiltersList
|
||||
domains={domains}
|
||||
tier={tier}
|
||||
totalCount={countsByType.web + countsByType.mail}
|
||||
totalLimit={limits.web + limits.mail}
|
||||
totalCount={domainCount}
|
||||
totalLimit={domainLimit}
|
||||
globalBlocklistCount={state.blocklistCount}
|
||||
onAddPress={() => setAddSheetOpen(true)}
|
||||
onSubmitDomain={submitDomain}
|
||||
|
||||
@ -113,16 +113,8 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
||||
return;
|
||||
}
|
||||
const raw = (result.error ?? '').toLowerCase();
|
||||
if (raw.includes('web_limit_reached')) {
|
||||
setError(t('blocker.error_web_limit_reached'));
|
||||
} else if (raw.includes('mail_limit_reached')) {
|
||||
setError(t('blocker.error_mail_limit_reached'));
|
||||
} else if (raw === 'limit_reached' || raw.includes('limit_reached')) {
|
||||
setError(
|
||||
kind === 'mail'
|
||||
? t('blocker.error_mail_limit_reached')
|
||||
: t('blocker.error_web_limit_reached'),
|
||||
);
|
||||
if (raw.includes('limit_reached')) {
|
||||
setError(t('blocker.error_limit_reached'));
|
||||
} else if (raw.includes('invalid_mail_domain') || raw.includes('display_name_not_supported')) {
|
||||
setError(t('blocker.error_invalid_mail'));
|
||||
} else if (raw.includes('invalid_domain') || raw.includes('invalid_pattern')) {
|
||||
|
||||
@ -38,16 +38,17 @@ export type AddDomainResult = {
|
||||
|
||||
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
|
||||
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 {
|
||||
const limit = plan === 'legend' ? 10 : 5;
|
||||
// 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 {
|
||||
@ -61,21 +62,13 @@ function deriveTier(plan: Plan, domains: CustomDomain[]): Tier {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
/** 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<void>;
|
||||
@ -113,43 +106,42 @@ export function isValidDomain(input: string): boolean {
|
||||
/**
|
||||
* 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
|
||||
* 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<CustomDomain[]>([]);
|
||||
const [apiCounts, setApiCounts] = useState<CountsByType | null>(null);
|
||||
const [apiLimits, setApiLimits] = useState<LimitsByType | null>(null);
|
||||
const [apiCount, setApiCount] = useState<number | null>(null);
|
||||
const [apiLimit, setApiLimit] = useState<number | 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.
|
||||
// { 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[]
|
||||
| { domains?: CustomDomain[] }
|
||||
| { items?: CustomDomain[]; counts?: CountsByType; limits?: LimitsByType }
|
||||
| { items?: CustomDomain[]; domains?: CustomDomain[]; count?: number; limit?: number }
|
||||
>('/api/custom-domains');
|
||||
let arr: CustomDomain[] = [];
|
||||
let counts: CountsByType | null = null;
|
||||
let limits: LimitsByType | null = null;
|
||||
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 ?? [];
|
||||
counts = (res as any).counts ?? null;
|
||||
limits = (res as any).limits ?? null;
|
||||
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);
|
||||
setApiCounts(counts);
|
||||
setApiLimits(limits);
|
||||
setApiCount(count);
|
||||
setApiLimit(limit);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
console.error('[useCustomDomains] fetch failed:', e?.message ?? e);
|
||||
@ -172,20 +164,12 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
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.
|
||||
// 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 && 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',
|
||||
};
|
||||
}
|
||||
if (!opts?.addToVip && apiCount != null && apiLimit != null && apiCount >= apiLimit) {
|
||||
return { ok: false, error: 'limit_reached' };
|
||||
}
|
||||
const pattern = resolvedKind === 'web' ? normalizeDomain(input) : input.trim();
|
||||
const body: Record<string, string | boolean> = { pattern };
|
||||
@ -207,7 +191,7 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
return { ok: false, error: e?.message ?? 'add_failed' };
|
||||
}
|
||||
},
|
||||
[apiCounts, apiLimits, fetchDomains],
|
||||
[apiCount, apiLimit, fetchDomains],
|
||||
);
|
||||
|
||||
const submitDomain = useCallback(
|
||||
@ -240,28 +224,18 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
|
||||
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,
|
||||
};
|
||||
// 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,
|
||||
countsByType,
|
||||
limits,
|
||||
count,
|
||||
limit,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchDomains,
|
||||
|
||||
@ -376,6 +376,7 @@
|
||||
"count_label": "%{count}/%{max}",
|
||||
"error_web_limit_reached": "Du hast alle Domain-Slots aufgebraucht. Entferne eine Domain oder upgrade auf Pro/Legend.",
|
||||
"error_mail_limit_reached": "Du hast alle Mail-Slots aufgebraucht. Entferne ein Mail-Pattern oder upgrade auf Pro/Legend.",
|
||||
"error_limit_reached": "Alle Domain-Slots belegt. Reiche eine Domain zur Freigabe ein — sobald sie aufgenommen ist, wird ein Slot frei.",
|
||||
"error_invalid_mail": "Bitte eine vollständige Mail-Adresse oder Mail-Domain eingeben (z.B. info@only4-subscribers.com).",
|
||||
"error_invalid_input": "Bitte eine gültige Domain oder Mail-Adresse eingeben.",
|
||||
"error_duplicate": "Diesen Eintrag hast du schon — er ist bereits in deiner Filter-Liste.",
|
||||
|
||||
@ -376,6 +376,7 @@
|
||||
"count_label": "%{count}/%{max}",
|
||||
"error_web_limit_reached": "You've used all your domain slots. Remove a domain or upgrade to Pro/Legend.",
|
||||
"error_mail_limit_reached": "You've used all your email slots. Remove an email pattern or upgrade to Pro/Legend.",
|
||||
"error_limit_reached": "All domain slots are full. Submit a domain for review — a slot frees up once it's accepted.",
|
||||
"error_invalid_mail": "Please enter a full email address or mail domain (e.g. info@only4-subscribers.com).",
|
||||
"error_invalid_input": "Please enter a valid domain or email address.",
|
||||
"error_duplicate": "You've already added this entry — it's in your filter list.",
|
||||
|
||||
@ -253,7 +253,6 @@ import { stripMarkdown } from "../../utils/strip-markdown";
|
||||
* Preise bleiben hier hardcoded — gehören eher zur Billing-Domain.
|
||||
*/
|
||||
function generatePlanDetails(): string {
|
||||
const free = PLAN_LIMITS.free;
|
||||
const pro = PLAN_LIMITS.pro;
|
||||
const legend = PLAN_LIMITS.legend;
|
||||
const fmtCount = (n: number) => (n === Infinity ? "Unbegrenzt" : String(n));
|
||||
@ -262,25 +261,18 @@ function generatePlanDetails(): string {
|
||||
? "(rückfüllbar – Slot wird wieder frei wenn die Domain global aufgenommen ODER von der Community abgelehnt wurde)"
|
||||
: "(NICHT rückfüllbar – einmal belegt, bleibt für immer belegt)";
|
||||
|
||||
return `Free (0 €):
|
||||
- Gambling-Blocker mit ${free.customDomains.web} eigenen Web-Domains + ${free.customDomains.mail} Mail-Patterns ${refillNote(free.domainRefill)}
|
||||
- Free kann Custom Domains NICHT zur Community-Abstimmung einreichen — das ist Pro/Legend exklusiv
|
||||
- ${fmtCount(free.mailAgents)} Mail-Konto, Scan alle ${free.mailIntervalOptions[0]}h
|
||||
return `Pro (3,99 € / Monat oder 29 € / Jahr – spare 19 %):
|
||||
- Gambling-Blocker mit Zugang zur vollständigen 208.000+ globalen Blocklist (Community-gepflegt)
|
||||
- ${pro.customDomains} eigene Domains, frei aufteilbar auf Web + Mail ${refillNote(pro.domainRefill)}
|
||||
- Bis zu ${fmtCount(pro.mailAgents)} Mail-Konten, Scan-Intervall wählbar (${pro.mailIntervalOptions.join("h / ")}h)
|
||||
- Streak-Tracker, SOS-Hilfe & Spiele-Sammlung, Atemübung
|
||||
- Community (lesen, posten, voten)
|
||||
- KI-Coach (du, Lyra – Basismodell)
|
||||
|
||||
Pro (3,99 € / Monat oder 29 € / Jahr – spare 19 %):
|
||||
- Alles aus Free PLUS:
|
||||
- Zugang zur vollständigen 208.000+ globalen Blocklist (Community-gepflegt)
|
||||
- ${pro.customDomains.web} Web-Domains + ${pro.customDomains.mail} Mail-Patterns ${refillNote(pro.domainRefill)}
|
||||
- Bis zu ${fmtCount(pro.mailAgents)} Mail-Konten, Scan-Intervall wählbar (${pro.mailIntervalOptions.join("h / ")}h)
|
||||
- Stärkeres KI-Modell (du, Lyra wirst zu einem 70B-Modell)
|
||||
- KI-Coach (du, Lyra – starkes 70B-Modell)
|
||||
- Kann Custom Domains zur Community-Abstimmung einreichen
|
||||
|
||||
Legend (7,99 € / Monat oder 59 € / Jahr – spare 38 %):
|
||||
- Alles aus Pro PLUS:
|
||||
- ${legend.customDomains.web} Web-Domains + ${legend.customDomains.mail} Mail-Patterns ${refillNote(legend.domainRefill)}
|
||||
- ${legend.customDomains} eigene Domains, frei aufteilbar auf Web + Mail ${refillNote(legend.domainRefill)}
|
||||
- ⭐ MULTI-DEVICE-SCHUTZ: App auf bis zu 3 WEITEREN Geräten gleichzeitig — Familie, Partner, Eltern können mitgeschützt werden ohne extra zu zahlen. Real-life-relevant: viele Betroffene haben mehrere Geräte (iPhone + iPad + alter Laptop)
|
||||
- ⭐ MAIL-DAEMON (echter technischer Durchbruch — Alleinstellungsmerkmal!): ${fmtCount(legend.mailAgents)} Mail-Konten mit Echtzeit-IMAP-IDLE-Überwachung. Casino-Mails werden in Sekunden permanent gelöscht — sie tauchen nicht mal im Papierkorb auf. Der User sieht nichts. Kein "Sie haben gewonnen!"-Trigger erreicht je das Postfach. Keine andere App im Markt kann das.
|
||||
- Privilegierte Domain-Einreichung: umgeht Community-Vote komplett. Domains werden direkt + priorisiert vom ReBreak-Admin geprüft (schneller als die 24h-Standard-Prüfung der Pro-Submissions). Vertrauensvorteil als Legend.
|
||||
|
||||
@ -1,21 +1,18 @@
|
||||
import { getUserCustomDomains, countActiveCustomDomainsSplit } from "../../db/domains";
|
||||
import { getUserCustomDomains, countActiveCustomDomains } from "../../db/domains";
|
||||
import { getProfile } from "../../db/profile";
|
||||
import { getPlanLimits } from "../../utils/plan-features";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
|
||||
const [items, split, profile] = await Promise.all([
|
||||
const [items, count, profile] = await Promise.all([
|
||||
getUserCustomDomains(user.id),
|
||||
countActiveCustomDomainsSplit(user.id),
|
||||
countActiveCustomDomains(user.id),
|
||||
getProfile(user.id),
|
||||
]);
|
||||
|
||||
const limits = getPlanLimits(profile?.plan ?? "free");
|
||||
// Slot-Pool: web + mail gemeinsam (Pro 10 / Legend 20).
|
||||
const limit = getPlanLimits(profile?.plan ?? "pro").customDomains;
|
||||
|
||||
return {
|
||||
items,
|
||||
counts: split,
|
||||
limits: limits.customDomains,
|
||||
};
|
||||
return { items, count, limit };
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { awardPoints } from "../../utils/scoring";
|
||||
import {
|
||||
addUserCustomDomain,
|
||||
countActiveCustomDomainsSplit,
|
||||
countActiveCustomDomains,
|
||||
CUSTOM_DOMAIN_TYPES,
|
||||
type CustomDomainType,
|
||||
} from "../../db/domains";
|
||||
@ -221,27 +221,22 @@ export default defineEventHandler(async (event) => {
|
||||
// !inGlobal → normaler Add unten
|
||||
}
|
||||
|
||||
// Per-type Slot-Limit prüfen — entfällt für webAddAsApproved (approved
|
||||
// belegt keinen Slot).
|
||||
const bucket: "web" | "mail" = type === "web" ? "web" : "mail";
|
||||
// Slot-Limit prüfen — EIN gemeinsamer Pool für web + mail (Pro 10 / Legend
|
||||
// 20). Entfällt für webAddAsApproved (approved belegt keinen Slot).
|
||||
if (!webAddAsApproved) {
|
||||
const profile = await getProfile(user.id);
|
||||
const limits = getPlanLimits(profile?.plan ?? "free");
|
||||
const bucketLimit = limits.customDomains[bucket];
|
||||
const limit = getPlanLimits(profile?.plan ?? "pro").customDomains;
|
||||
|
||||
if (bucketLimit !== Infinity) {
|
||||
const split = await countActiveCustomDomainsSplit(user.id);
|
||||
const currentCount = split[bucket];
|
||||
if (currentCount >= bucketLimit) {
|
||||
const errorCode = bucket === "web" ? "WEB_LIMIT_REACHED" : "MAIL_LIMIT_REACHED";
|
||||
if (limit !== Infinity) {
|
||||
const currentCount = await countActiveCustomDomains(user.id);
|
||||
if (currentCount >= limit) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
data: {
|
||||
error: errorCode,
|
||||
error: "LIMIT_REACHED",
|
||||
resource: "custom_domains",
|
||||
bucket,
|
||||
current: currentCount,
|
||||
limit: bucketLimit,
|
||||
limit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -144,14 +144,12 @@ export default defineEventHandler(async (event): Promise<ChangePreviewResponse>
|
||||
}
|
||||
|
||||
// ── Custom Domains ────────────────────────────────────────────────────────
|
||||
// Compare total (web + mail) to detect any bucket change
|
||||
const fromTotalDomains = fromLimits.customDomains.web + fromLimits.customDomains.mail;
|
||||
const toTotalDomains = toLimits.customDomains.web + toLimits.customDomains.mail;
|
||||
// Gemeinsamer Pool für web + mail (Pro 10 / Legend 20).
|
||||
const fromTotalDomains = fromLimits.customDomains;
|
||||
const toTotalDomains = toLimits.customDomains;
|
||||
if (fromTotalDomains !== toTotalDomains) {
|
||||
if (direction === "downgrade") {
|
||||
const newLimitWeb = toLimits.customDomains.web;
|
||||
const newLimitMail = toLimits.customDomains.mail;
|
||||
const newLimitTotal = newLimitWeb + newLimitMail;
|
||||
const newLimitTotal = toLimits.customDomains;
|
||||
const overBy = Math.max(0, activeDomainCount - newLimitTotal);
|
||||
changes.push({
|
||||
resource: "custom_domains",
|
||||
@ -161,15 +159,13 @@ export default defineEventHandler(async (event): Promise<ChangePreviewResponse>
|
||||
action: "keep", // grandfathered — alle bleiben aktiv
|
||||
detail:
|
||||
overBy > 0
|
||||
? `Du hast ${activeDomainCount} eigene Domains, ${to}-Plan erlaubt ${newLimitTotal} (${newLimitWeb} Web + ${newLimitMail} Mail). ` +
|
||||
`Alle bleiben aktiv — du kannst erst wieder welche hinzufügen wenn du unter dem jeweiligen Limit bist.`
|
||||
: `Du hast ${activeDomainCount} von ${newLimitTotal} möglichen Domains (${newLimitWeb} Web + ${newLimitMail} Mail) — kein Überlauf.`,
|
||||
? `Du hast ${activeDomainCount} eigene Domains, ${to}-Plan erlaubt ${newLimitTotal}. ` +
|
||||
`Alle bleiben aktiv — du kannst erst wieder welche hinzufügen wenn du unter dem Limit bist.`
|
||||
: `Du hast ${activeDomainCount} von ${newLimitTotal} möglichen Domains — kein Überlauf.`,
|
||||
});
|
||||
} else {
|
||||
const web = toLimits.customDomains.web;
|
||||
const mail = toLimits.customDomains.mail;
|
||||
gains.push(
|
||||
`Bis zu ${web} Web-Domains + ${mail} Mail-Patterns${toLimits.domainRefill ? " (Slots füllen sich auf wenn deine Domain in die globale Liste aufgenommen wird)" : ""}`,
|
||||
`Bis zu ${toLimits.customDomains} eigene Domains, Web oder Mail${toLimits.domainRefill ? " (Slots füllen sich auf wenn deine Domain in die globale Liste aufgenommen wird)" : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,11 +74,12 @@ export async function getUserCustomDomains(userId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts domains that occupy a slot (active + submitted).
|
||||
* approved → slot freed (domain joined global list)
|
||||
* rejected → slot freed (user can re-submit or delete)
|
||||
* Zählt die Domains, die einen Slot belegen (active + submitted).
|
||||
* approved → Slot frei (Domain in globale Liste aufgenommen)
|
||||
* rejected → Slot frei (User kann neu einreichen)
|
||||
*
|
||||
* @deprecated Use countActiveCustomDomainsSplit for per-type quota checks.
|
||||
* Single Source of Truth für das Slot-Limit — web + mail teilen sich EINEN
|
||||
* gemeinsamen Pool (Pro 10 / Legend 20).
|
||||
*/
|
||||
export async function countActiveCustomDomains(userId: string) {
|
||||
const db = usePrisma();
|
||||
|
||||
@ -13,17 +13,10 @@ export interface VoiceConfig {
|
||||
dailyQuotaSeconds: number;
|
||||
}
|
||||
|
||||
export interface CustomDomainLimits {
|
||||
/** Max. Web-Domain-Slots (Infinity = unbegrenzt) */
|
||||
web: number;
|
||||
/** Max. Mail-Slots — kombiniert für mail_domain + mail_display_name */
|
||||
mail: number;
|
||||
}
|
||||
|
||||
export interface PlanLimits {
|
||||
// ─── Custom Domains ──────────────────────────────────────────────────────
|
||||
/** Max. eigene Domains aufgeteilt nach Typ-Bucket */
|
||||
customDomains: CustomDomainLimits;
|
||||
/** Max. eigene Domains — EIN gemeinsamer Pool für web + mail (Infinity = unbegrenzt) */
|
||||
customDomains: number;
|
||||
/** Freigeschaltete Domain-Slots füllen sich wieder auf (Community-Promotion) */
|
||||
domainRefill: boolean;
|
||||
|
||||
@ -84,33 +77,10 @@ export interface PlanLimits {
|
||||
voice: VoiceConfig;
|
||||
}
|
||||
|
||||
export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||
free: {
|
||||
customDomains: { web: 5, mail: 5 },
|
||||
domainRefill: false,
|
||||
mailAgents: 1,
|
||||
mailIntervalOptions: [4],
|
||||
globalBlocklist: "curated",
|
||||
canPost: true,
|
||||
canCreateGroup: false,
|
||||
canAddToBlocklist: false,
|
||||
maxAppDevices: 1,
|
||||
maxProtectedDevices: 0,
|
||||
aiModel: "llama-3.1-8b-instant",
|
||||
aiModelFallbacks: [
|
||||
{ provider: "groq", model: "llama-3.3-70b-versatile" },
|
||||
{ provider: "groq", model: "gemma2-9b-it" },
|
||||
{ provider: "openrouter", model: "meta-llama/llama-3.1-8b-instruct" },
|
||||
],
|
||||
aiProvider: "groq",
|
||||
voice: {
|
||||
provider: "google",
|
||||
model: "de-DE-Neural2-F", // Google Cloud TTS Neural2 — natural, ~$4/1M chars
|
||||
dailyQuotaSeconds: 60, // 1 Minute/Tag
|
||||
},
|
||||
},
|
||||
// Free-Tier ist entfallen — es gibt nur noch Pro + Legend.
|
||||
export const PLAN_LIMITS: Record<Exclude<Plan, "free">, PlanLimits> = {
|
||||
pro: {
|
||||
customDomains: { web: 5, mail: 5 },
|
||||
customDomains: 10,
|
||||
domainRefill: true,
|
||||
mailAgents: 3,
|
||||
mailIntervalOptions: [1, 4, 8],
|
||||
@ -133,7 +103,7 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
customDomains: { web: 10, mail: 10 },
|
||||
customDomains: 20,
|
||||
domainRefill: true,
|
||||
mailAgents: Infinity,
|
||||
mailIntervalOptions: [1, 4, 8],
|
||||
@ -158,10 +128,10 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||
};
|
||||
|
||||
export function getPlanLimits(plan: string): PlanLimits {
|
||||
// Legacy-Pläne auf neue Namen mappen
|
||||
if (plan === "premium") return PLAN_LIMITS.legend;
|
||||
if (plan === "standard") return PLAN_LIMITS.pro;
|
||||
return PLAN_LIMITS[(plan as Plan) ?? "free"] ?? PLAN_LIMITS.free;
|
||||
// Free-Tier ist entfallen — alles außer Legend bekommt Pro-Limits.
|
||||
// Legacy-Namen: premium → legend, standard → pro.
|
||||
if (plan === "legend" || plan === "premium") return PLAN_LIMITS.legend;
|
||||
return PLAN_LIMITS.pro;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user