import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "../i18n"; import type { TranslationKey } from "../i18n/zh"; import { providerPresets, type BillingMode, type PresetCategory, type ProviderPreset, } from "../config/providerPresets"; import { ProviderIcon } from "./ProviderIcon"; interface PresetPickerModalProps { open: boolean; onClose: () => void; onSelect: (preset: ProviderPreset) => void; onCustom: () => void; } const CATEGORY_BADGE: Record, string> = { official: "bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400", cn_official: "bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-400", third_party: "bg-gray-200 text-gray-600 dark:bg-gray-500/15 dark:text-gray-400", aggregator: "bg-purple-100 text-purple-700 dark:bg-purple-500/15 dark:text-purple-400", }; const CATEGORY_LABEL: Record, TranslationKey> = { official: "presetCategoryOfficial", cn_official: "presetCategoryCnOfficial", third_party: "presetCategoryThirdParty", aggregator: "presetCategoryAggregator", }; const BILLING_BADGE: Record = { subscription: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-400", pay_as_you_go: "bg-slate-200 text-slate-600 dark:bg-slate-500/15 dark:text-slate-400", }; const BILLING_LABEL: Record = { subscription: "billingBadgeSubscription", pay_as_you_go: "billingBadgePayAsYouGo", }; type SortMode = "original" | "alpha"; type BillingTab = "all" | BillingMode; const BILLING_TAB_ORDER: BillingTab[] = ["subscription", "pay_as_you_go", "all"]; export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) { const { t } = useTranslation(); const [query, setQuery] = useState(""); const [sort, setSort] = useState("original"); const [billingTab, setBillingTab] = useState("all"); // Esc to close useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); // Scroll lock while open useEffect(() => { if (!open) return; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; }, [open]); // Reset transient state each time the modal opens useEffect(() => { if (open) { setQuery(""); setSort("original"); setBillingTab("all"); } }, [open]); // Counts per billing tab — shown in the tab labels so users see how many // presets are in each bucket without having to click through. const billingCounts = useMemo(() => { let sub = 0; let payg = 0; for (const p of providerPresets) { if (p.billingMode === "subscription") sub++; else payg++; } return { subscription: sub, pay_as_you_go: payg, all: sub + payg }; }, []); const presetName = (p: ProviderPreset) => p.nameKey ? t(p.nameKey as TranslationKey) : p.name; const visible = useMemo(() => { const q = query.trim().toLowerCase(); let list = providerPresets; if (billingTab !== "all") { list = list.filter((p) => p.billingMode === billingTab); } if (q) { list = list.filter( (p) => p.id.toLowerCase().includes(q) || presetName(p).toLowerCase().includes(q) ); } if (sort === "alpha") { list = [...list].sort((a, b) => presetName(a).localeCompare(presetName(b))); } return list; // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, sort, billingTab, t]); if (!open) return null; return createPortal(
e.stopPropagation()} > {/* Header */}
{t("presetPickerTitle")}
{/* Billing-mode tabs + search + sort */}
{BILLING_TAB_ORDER.map((tab) => { const labelKey: TranslationKey = tab === "all" ? "billingTabAll" : tab === "subscription" ? "billingTabSubscription" : "billingTabPayAsYouGo"; const count = billingCounts[tab]; const active = billingTab === tab; return ( ); })}
setQuery(e.target.value)} placeholder={t("presetSearchPlaceholder")} autoFocus className="h-8 flex-1 rounded border border-border bg-input px-2 text-sm text-content-primary focus:outline-none focus:ring-2 focus:ring-blue-500" />
{/* Preset grid */}
{visible.length === 0 ? (
{t("presetNoResults")}
) : (
{visible.map((p) => ( ))}
)} {/* Custom (empty form) entry */}
{/* Footer hint */}
{t("presetFooterHint")}
, document.body ); }