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:
chahinebrini 2026-05-22 18:40:28 +02:00
parent 7cc30db020
commit 704958320b
11 changed files with 93 additions and 174 deletions

View File

@ -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}

View File

@ -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')) {

View File

@ -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,

View File

@ -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.",

View File

@ -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.",

View File

@ -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.

View File

@ -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 };
});

View File

@ -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,
},
});
}

View File

@ -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)" : ""}`,
);
}
}

View File

@ -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();

View File

@ -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;
}
/**