@burdenoff/microfe-subscriberbot 2026.1030.7 → 2026.1031.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +26 -0
  2. package/dist/stories/pages/AgentDetailPage.stories.d.ts +16 -0
  3. package/dist/stories/pages/AgentDetailPage.stories.d.ts.map +1 -0
  4. package/dist/stories/pages/AgentNewPage.stories.d.ts +12 -0
  5. package/dist/stories/pages/AgentNewPage.stories.d.ts.map +1 -0
  6. package/dist/stories/pages/AgentsListPage.stories.d.ts +16 -0
  7. package/dist/stories/pages/AgentsListPage.stories.d.ts.map +1 -0
  8. package/dist/stories/pages/AssetVaultPage.stories.d.ts +14 -0
  9. package/dist/stories/pages/AssetVaultPage.stories.d.ts.map +1 -0
  10. package/dist/stories/pages/DashboardPage.stories.d.ts +12 -0
  11. package/dist/stories/pages/DashboardPage.stories.d.ts.map +1 -0
  12. package/dist/stories/pages/DecisionDetailPage.stories.d.ts +13 -0
  13. package/dist/stories/pages/DecisionDetailPage.stories.d.ts.map +1 -0
  14. package/dist/stories/pages/DecisionsPage.stories.d.ts +14 -0
  15. package/dist/stories/pages/DecisionsPage.stories.d.ts.map +1 -0
  16. package/dist/stories/pages/EnterpriseSpendPage.stories.d.ts +13 -0
  17. package/dist/stories/pages/EnterpriseSpendPage.stories.d.ts.map +1 -0
  18. package/dist/stories/pages/InboxPage.stories.d.ts +20 -0
  19. package/dist/stories/pages/InboxPage.stories.d.ts.map +1 -0
  20. package/dist/stories/pages/InsightsPage.stories.d.ts +12 -0
  21. package/dist/stories/pages/InsightsPage.stories.d.ts.map +1 -0
  22. package/dist/stories/pages/InvoicesPage.stories.d.ts +16 -0
  23. package/dist/stories/pages/InvoicesPage.stories.d.ts.map +1 -0
  24. package/dist/stories/pages/ProviderDetailPage.stories.d.ts +15 -0
  25. package/dist/stories/pages/ProviderDetailPage.stories.d.ts.map +1 -0
  26. package/dist/stories/pages/ProviderNewPage.stories.d.ts +12 -0
  27. package/dist/stories/pages/ProviderNewPage.stories.d.ts.map +1 -0
  28. package/dist/stories/pages/ProvidersListPage.stories.d.ts +17 -0
  29. package/dist/stories/pages/ProvidersListPage.stories.d.ts.map +1 -0
  30. package/dist/stories/pages/RelationshipDetailPage.stories.d.ts +15 -0
  31. package/dist/stories/pages/RelationshipDetailPage.stories.d.ts.map +1 -0
  32. package/dist/stories/pages/RelationshipNewPage.stories.d.ts +12 -0
  33. package/dist/stories/pages/RelationshipNewPage.stories.d.ts.map +1 -0
  34. package/dist/stories/pages/RelationshipsListPage.stories.d.ts +14 -0
  35. package/dist/stories/pages/RelationshipsListPage.stories.d.ts.map +1 -0
  36. package/dist/stories/pages/SavingsPage.stories.d.ts +13 -0
  37. package/dist/stories/pages/SavingsPage.stories.d.ts.map +1 -0
  38. package/dist/stories/pages/WorkflowsPage.stories.d.ts +16 -0
  39. package/dist/stories/pages/WorkflowsPage.stories.d.ts.map +1 -0
  40. package/dist/stories/support/PageHarness.d.ts +28 -0
  41. package/dist/stories/support/PageHarness.d.ts.map +1 -0
  42. package/dist/stories/support/fixtures.d.ts +28 -0
  43. package/dist/stories/support/fixtures.d.ts.map +1 -0
  44. package/dist/stories/support/graphqlMock.d.ts +34 -0
  45. package/dist/stories/support/graphqlMock.d.ts.map +1 -0
  46. package/dist/stories/support/handlers.d.ts +8 -0
  47. package/dist/stories/support/handlers.d.ts.map +1 -0
  48. package/dist/stories/support/play.d.ts +7 -0
  49. package/dist/stories/support/play.d.ts.map +1 -0
  50. package/dist/subscriberbot/components/Modal.d.ts.map +1 -1
  51. package/dist/subscriberbot/components/Modal.js +9 -6
  52. package/dist/subscriberbot/components/Modal.js.map +1 -1
  53. package/dist/subscriberbot/pages/InvoicesPage.d.ts.map +1 -1
  54. package/dist/subscriberbot/pages/InvoicesPage.js +1 -0
  55. package/dist/subscriberbot/pages/InvoicesPage.js.map +1 -1
  56. package/dist/subscriberbot/pages/enterprise/EnterpriseSpendPage.d.ts.map +1 -1
  57. package/dist/subscriberbot/pages/enterprise/EnterpriseSpendPage.js +1 -0
  58. package/dist/subscriberbot/pages/enterprise/EnterpriseSpendPage.js.map +1 -1
  59. package/dist/subscriberbot/pages/providers/ProvidersListPage.d.ts.map +1 -1
  60. package/dist/subscriberbot/pages/providers/ProvidersListPage.js +1 -0
  61. package/dist/subscriberbot/pages/providers/ProvidersListPage.js.map +1 -1
  62. package/dist/subscriberbot/utils/formatters.d.ts +1 -0
  63. package/dist/subscriberbot/utils/formatters.d.ts.map +1 -1
  64. package/dist/subscriberbot/utils/formatters.js +4 -3
  65. package/dist/subscriberbot/utils/formatters.js.map +1 -1
  66. package/package.json +2 -1
@@ -1 +1 @@
1
- {"version":3,"file":"EnterpriseSpendPage.js","names":[],"sources":["../../../../src/subscriberbot/pages/enterprise/EnterpriseSpendPage.tsx"],"sourcesContent":["/**\n * Enterprise SaaS spend management\n * @module subscriberbot/pages/enterprise\n *\n * `/subscriberbot/enterprise/spend` (BOFF-4008) — the finance / IT leadership\n * view of recurring spend: company-wide spend and trend, budget variance,\n * spend by department, duplicate tools, shadow IT, the quarterly renewal\n * pipeline, compliance / risk flags and the licence table. Every figure comes\n * from the real `subEnterpriseSpendDashboard` query, which aggregates the\n * live portfolio server-side; nothing on this page is mocked.\n */\n\nimport { useMemo, useState, type FC, type ReactNode } from 'react';\nimport {\n AlertTriangle,\n Building2,\n CalendarClock,\n Copy,\n EyeOff,\n Layers,\n PiggyBank,\n ShieldAlert,\n Target,\n Users,\n Wallet,\n} from 'lucide-react';\nimport { GlassCard, PagePurpose } from '@burdenoff/fe-libs/ui';\nimport {\n BarList,\n EmptyState,\n ErrorView,\n LoadingView,\n MetricsCard,\n MiniProgress,\n Modal,\n PageLayout,\n Section,\n Sparkline,\n} from '../../components';\nimport { useSubscriberBot } from '../../context/SubscriberBotContext';\nimport {\n useEnterpriseSpendDashboard,\n useSetSpendBudget,\n useSpendBudgets,\n} from '../../hooks/useSubscriberBotApi';\nimport { useTr } from '../../hooks/useTr';\nimport { formatCurrency, formatDate } from '../../utils/formatters';\nimport { cn } from '../../utils/cn';\nimport type {\n SubBudgetStatus,\n SubEnterpriseSpendDashboard,\n SubOverlapType,\n SubShadowItReason,\n SubSpendRiskKind,\n SubSpendRiskSeverity,\n} from '../../types';\n\ntype Tr = ReturnType<typeof useTr>;\n\nconst SHADOW_REASON: Record<SubShadowItReason, { key: string; fallback: string }> = {\n UNCATALOGUED: { key: 'enterprise.shadow.reasons.uncatalogued', fallback: 'Not in the catalogue' },\n UNVERIFIED_PROVIDER: {\n key: 'enterprise.shadow.reasons.unverified',\n fallback: 'Unverified provider',\n },\n DEPRECATED_PROVIDER: {\n key: 'enterprise.shadow.reasons.deprecated',\n fallback: 'Deprecated provider',\n },\n};\n\nconst OVERLAP_TYPE: Record<SubOverlapType, { key: string; fallback: string }> = {\n EXACT_DUPLICATE: { key: 'enterprise.duplicates.types.exact', fallback: 'Same vendor' },\n CAPABILITY: { key: 'enterprise.duplicates.types.capability', fallback: 'Same capability' },\n ENTITLEMENT: {\n key: 'enterprise.duplicates.types.entitlement',\n fallback: 'Covered by another plan',\n },\n};\n\nconst RISK_KIND: Record<SubSpendRiskKind, { key: string; fallback: string }> = {\n PAST_DUE: { key: 'enterprise.risks.kinds.pastDue', fallback: 'Payment failed' },\n MISSING_INVOICE: { key: 'enterprise.risks.kinds.missingInvoice', fallback: 'Missing invoice' },\n UNTRACKED_TRIAL: { key: 'enterprise.risks.kinds.untrackedTrial', fallback: 'Untracked trial' },\n HIGH_COST_AI: { key: 'enterprise.risks.kinds.highCostAi', fallback: 'High-cost AI service' },\n};\n\nconst SEVERITY_STYLE: Record<SubSpendRiskSeverity, string> = {\n HIGH: 'bg-status-error-bg-subtle text-status-error-text',\n MEDIUM: 'bg-status-warning-bg-subtle text-status-warning-text',\n LOW: 'bg-bg-sunken text-text-secondary',\n};\n\nconst inputCls =\n 'w-full rounded-lg border border-border-subtle bg-bg-surface px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]';\nconst labelCls = 'mb-1.5 block text-xs font-medium text-text-secondary';\nconst linkCls =\n 'truncate text-left text-sm font-medium text-text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] rounded';\n\nfunction departmentName(tr: Tr, department: string | null): string {\n return department ?? tr('enterprise.departments.unassigned', 'Unassigned');\n}\n\n/** \"62% of 5,000.00 budget\" pill for a budget status. */\nconst BudgetMeter: FC<{ status: SubBudgetStatus; fmt: (n: number) => string; tr: Tr }> = ({\n status,\n fmt,\n tr,\n}) => (\n <div className=\"space-y-1.5\">\n <MiniProgress\n value={Math.min(status.utilizationPct, 100)}\n tone={status.overBudget ? 'negative' : status.utilizationPct >= 85 ? 'warning' : 'positive'}\n />\n <p\n className={cn(\n 'text-xs tabular-nums',\n status.overBudget ? 'text-status-error-text' : 'text-text-secondary'\n )}\n >\n {status.overBudget\n ? tr('enterprise.budget.over', '{{amount}} over a {{budget}} budget', {\n amount: fmt(-status.variance),\n budget: fmt(status.monthlyBudget),\n })\n : tr('enterprise.budget.under', '{{amount}} left of {{budget}} ({{pct}}% used)', {\n amount: fmt(status.variance),\n budget: fmt(status.monthlyBudget),\n pct: status.utilizationPct,\n })}\n </p>\n </div>\n);\n\nconst Card: FC<{ title: string; icon: ReactNode; children: ReactNode; testId?: string }> = ({\n title,\n icon,\n children,\n testId,\n}) => (\n <GlassCard treatment=\"plain\" className=\"p-6\" data-testid={testId}>\n <div className=\"mb-4 flex items-center gap-2\">\n <span className=\"text-text-secondary\">{icon}</span>\n <h3 className=\"text-sm font-semibold text-text-primary\">{title}</h3>\n </div>\n {children}\n </GlassCard>\n);\n\n/** Budgets editor — company-wide and per-department monthly budgets. */\nconst BudgetsModal: FC<{\n currency: string;\n departments: string[];\n onClose: () => void;\n}> = ({ currency, departments, onClose }) => {\n const tr = useTr();\n const budgetsQ = useSpendBudgets();\n const setBudget = useSetSpendBudget();\n const [department, setDepartment] = useState('');\n const [amount, setAmount] = useState('');\n const [error, setError] = useState<string | null>(null);\n\n const budgets = (budgetsQ.data ?? []).filter((b) => b.currency === currency);\n\n const save = (dept: string | null, monthlyAmount: number | null) => {\n setError(null);\n setBudget.mutate(\n { department: dept, currency, monthlyAmount },\n {\n onSuccess: () => {\n setAmount('');\n setDepartment('');\n },\n onError: (err) =>\n setError(\n err instanceof Error\n ? err.message\n : tr('enterprise.budgets.error', 'Could not save the budget.')\n ),\n }\n );\n };\n\n const handleSubmit = (e: React.FormEvent) => {\n e.preventDefault();\n const value = Number(amount);\n if (!amount.trim() || !Number.isFinite(value) || value <= 0) {\n setError(tr('enterprise.budgets.amountRequired', 'Enter a monthly amount above zero.'));\n return;\n }\n save(department.trim() || null, value);\n };\n\n return (\n <Modal\n title={tr('enterprise.budgets.title', 'Monthly budgets')}\n description={tr(\n 'enterprise.budgets.description',\n 'Budgets apply to spend billed in {{currency}}. Leave the department empty for the company-wide budget.',\n { currency }\n )}\n onClose={onClose}\n maxWidthClassName=\"max-w-xl\"\n >\n <form onSubmit={handleSubmit} className=\"space-y-4\">\n {error && (\n <div className=\"rounded-lg border border-status-error-text/30 bg-status-error-bg-subtle px-4 py-2 text-sm text-status-error-text\">\n {error}\n </div>\n )}\n <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-[1fr_10rem_auto] sm:items-end\">\n <div>\n <label htmlFor=\"budget-department\" className={labelCls}>\n {tr('enterprise.budgets.department', 'Department (optional)')}\n </label>\n <input\n id=\"budget-department\"\n list=\"budget-department-options\"\n value={department}\n maxLength={120}\n onChange={(e) => setDepartment(e.target.value)}\n placeholder={tr('enterprise.budgets.companyWide', 'Company-wide')}\n className={inputCls}\n />\n <datalist id=\"budget-department-options\">\n {departments.map((d) => (\n <option key={d} value={d} />\n ))}\n </datalist>\n </div>\n <div>\n <label htmlFor=\"budget-amount\" className={labelCls}>\n {tr('enterprise.budgets.amount', 'Per month ({{currency}})', { currency })}\n </label>\n <input\n id=\"budget-amount\"\n type=\"number\"\n min=\"0\"\n step=\"0.01\"\n inputMode=\"decimal\"\n value={amount}\n onChange={(e) => setAmount(e.target.value)}\n className={inputCls}\n />\n </div>\n <button\n type=\"submit\"\n disabled={setBudget.isPending}\n className=\"rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text hover:opacity-90 disabled:opacity-60\"\n >\n {tr('enterprise.budgets.save', 'Save budget')}\n </button>\n </div>\n </form>\n\n <div className=\"mt-6\">\n {budgetsQ.isLoading ? (\n <LoadingView label={tr('enterprise.budgets.loading', 'Loading budgets…')} />\n ) : budgets.length === 0 ? (\n <p className=\"text-sm text-text-secondary\">\n {tr('enterprise.budgets.none', 'No budgets set for {{currency}} yet.', { currency })}\n </p>\n ) : (\n <ul className=\"divide-y divide-border-subtle rounded-lg border border-border-subtle\">\n {budgets.map((b) => (\n <li key={b.id} className=\"flex items-center justify-between gap-3 px-4 py-3\">\n <span className=\"truncate text-sm text-text-primary\">\n {b.department ?? tr('enterprise.budgets.companyWide', 'Company-wide')}\n </span>\n <span className=\"flex items-center gap-3\">\n <span className=\"text-sm font-semibold tabular-nums text-text-primary\">\n {formatCurrency(b.monthlyAmount, b.currency)}\n </span>\n <button\n type=\"button\"\n onClick={() => save(b.department, null)}\n disabled={setBudget.isPending}\n className=\"text-xs font-medium text-status-error-text hover:underline disabled:opacity-60\"\n >\n {tr('enterprise.budgets.remove', 'Remove')}\n </button>\n </span>\n </li>\n ))}\n </ul>\n )}\n </div>\n </Modal>\n );\n};\n\nexport function EnterpriseSpendPage() {\n const tr = useTr();\n const { navigateTo } = useSubscriberBot();\n const [currency, setCurrency] = useState<string | null>(null);\n const [budgetsOpen, setBudgetsOpen] = useState(false);\n const query = useEnterpriseSpendDashboard(currency);\n const data: SubEnterpriseSpendDashboard | undefined = query.data;\n\n const fmt = useMemo(() => {\n const code = data?.currency ?? 'USD';\n return (n: number) => formatCurrency(n, code);\n }, [data?.currency]);\n\n const openRelationship = (id: string) => navigateTo(`/relationships/${id}`);\n\n if (query.isLoading)\n return <LoadingView label={tr('enterprise.loading', 'Aggregating company-wide spend…')} />;\n if (query.isError || !data)\n return (\n <ErrorView\n message={tr('enterprise.error', 'Could not load the enterprise spend dashboard.')}\n onRetry={() => void query.refetch()}\n />\n );\n\n const nextQuarter = data.renewalPipeline[0];\n const change = data.monthlySpendChangePct;\n const currencyOptions = [data.currency, ...data.otherCurrencies.map((c) => c.currency)];\n const departmentNames = data.departments.map((d) => d.department).filter((d): d is string => !!d);\n const isEmpty =\n data.activeRelationshipCount === 0 &&\n data.shadowItCount === 0 &&\n data.riskFlagCount === 0 &&\n data.departments.length === 0;\n\n return (\n <PageLayout\n title={tr('enterprise.title', 'SaaS spend management')}\n description={tr(\n 'enterprise.description',\n 'Company-wide recurring spend by department, with duplicate tools, shadow IT, renewals and risks.'\n )}\n actions={\n <div className=\"flex items-center gap-2\">\n {currencyOptions.length > 1 && (\n <label className=\"flex items-center gap-2 text-xs text-text-secondary\">\n {tr('enterprise.currency', 'Currency')}\n <select\n aria-label={tr('enterprise.currency', 'Currency')}\n value={data.currency}\n onChange={(e) => setCurrency(e.target.value)}\n className=\"rounded-lg border border-border-subtle bg-bg-surface px-2 py-1.5 text-xs text-text-primary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n >\n {currencyOptions.map((c) => (\n <option key={c} value={c}>\n {c}\n </option>\n ))}\n </select>\n </label>\n )}\n <button\n type=\"button\"\n onClick={() => setBudgetsOpen(true)}\n className=\"inline-flex items-center gap-2 rounded-lg border border-border-subtle px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-sunken\"\n >\n <Target className=\"h-4 w-4\" aria-hidden /> {tr('enterprise.manageBudgets', 'Budgets')}\n </button>\n </div>\n }\n >\n <PagePurpose className=\"mb-6\">\n {tr(\n 'enterprise.purpose',\n 'For finance, IT and procurement. Spend is the monthly run-rate of every subscription billing right now, computed live from your portfolio in one currency at a time. Allocate subscriptions to a department on the subscription itself.'\n )}\n </PagePurpose>\n\n {data.truncated && (\n <p className=\"mb-4 rounded-lg bg-status-warning-bg-subtle px-4 py-2 text-sm text-status-warning-text\">\n {tr(\n 'enterprise.truncated',\n 'This workspace has more subscriptions than one dashboard aggregates; figures cover the most recent ones.'\n )}\n </p>\n )}\n\n {isEmpty ? (\n <EmptyState\n icon={<Building2 className=\"h-8 w-8\" />}\n title={tr('enterprise.emptyTitle', 'No company spend to analyse yet')}\n description={tr(\n 'enterprise.emptyDescription',\n 'Add your SaaS subscriptions and allocate them to departments to see spend, duplicates, shadow IT and renewals here.'\n )}\n action={\n <button\n type=\"button\"\n onClick={() => navigateTo('/relationships/new')}\n className=\"rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text hover:opacity-90\"\n >\n {tr('enterprise.emptyAction', 'Add a subscription')}\n </button>\n }\n />\n ) : (\n <div className=\"space-y-6\">\n {/* KPI header */}\n <div\n className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4\"\n data-testid=\"enterprise-kpis\"\n >\n <MetricsCard\n featured\n icon={<Wallet className=\"h-5 w-5\" />}\n value={fmt(data.annualSpend)}\n label={tr(\n 'enterprise.kpis.annualSpend',\n 'Total SaaS spend / year ({{monthly}} / month)',\n {\n monthly: fmt(data.monthlySpend),\n }\n )}\n trend={\n change == null ? undefined : change > 0 ? 'up' : change < 0 ? 'down' : 'neutral'\n }\n trendValue={change == null ? undefined : `${change > 0 ? '+' : ''}${change}%`}\n />\n <MetricsCard\n icon={<Layers className=\"h-5 w-5\" />}\n value={data.activeRelationshipCount}\n label={tr('enterprise.kpis.activeLicenses', 'Active licences')}\n />\n <MetricsCard\n icon={<Users className=\"h-5 w-5\" />}\n value={data.seatUtilizationPct != null ? `${data.seatUtilizationPct}%` : '—'}\n label={\n data.seatUtilizationPct != null\n ? tr('enterprise.kpis.seats', 'Seats utilised ({{used}} of {{assigned}})', {\n used: data.seatsUsed,\n assigned: data.seatsAssigned,\n })\n : tr('enterprise.kpis.seatsUntracked', 'Seats utilised (no seat data yet)')\n }\n />\n <MetricsCard\n icon={<CalendarClock className=\"h-5 w-5\" />}\n value={nextQuarter ? nextQuarter.relationshipCount : 0}\n label={tr('enterprise.kpis.renewals', 'Renewals this quarter ({{amount}})', {\n amount: fmt(nextQuarter?.amount ?? 0),\n })}\n />\n </div>\n\n {/* Trend + budget */}\n <div className=\"grid grid-cols-1 gap-4 lg:grid-cols-3\">\n <Card\n title={tr('enterprise.trend.title', 'Monthly run-rate, last 150 days')}\n icon={<Wallet className=\"h-4 w-4\" />}\n >\n <Sparkline\n points={data.trend.map((p) => p.monthlySpend)}\n positive={(change ?? 0) <= 0}\n className=\"w-full\"\n />\n <p className=\"mt-2 text-xs text-text-secondary\">\n {tr('enterprise.trend.vs', '{{now}} now vs {{before}} 30 days ago', {\n now: fmt(data.monthlySpend),\n before: fmt(data.previousMonthlySpend),\n })}\n </p>\n </Card>\n <Card\n title={tr('enterprise.budget.title', 'Company budget')}\n icon={<Target className=\"h-4 w-4\" />}\n testId=\"enterprise-budget\"\n >\n {data.budget ? (\n <BudgetMeter status={data.budget} fmt={fmt} tr={tr} />\n ) : (\n <div className=\"space-y-3\">\n <p className=\"text-sm text-text-secondary\">\n {tr('enterprise.budget.none', 'No company-wide budget set for {{currency}}.', {\n currency: data.currency,\n })}\n </p>\n <button\n type=\"button\"\n onClick={() => setBudgetsOpen(true)}\n className=\"text-sm font-medium text-text-primary underline\"\n >\n {tr('enterprise.budget.set', 'Set a budget')}\n </button>\n </div>\n )}\n </Card>\n <Card\n title={tr('enterprise.savings.title', 'Consolidation opportunity')}\n icon={<PiggyBank className=\"h-4 w-4\" />}\n >\n <p className=\"text-2xl font-bold tabular-nums text-text-primary\">\n {fmt(data.duplicatePotentialMonthlySavings)}\n <span className=\"text-sm font-normal text-text-secondary\">\n {tr('enterprise.perMonth', '/mo')}\n </span>\n </p>\n <p className=\"mt-1 text-xs text-text-secondary\">\n {tr(\n 'enterprise.savings.detail',\n 'Upper bound if each duplicated tool is consolidated into one subscription; shadow IT costs {{shadow}} / month.',\n { shadow: fmt(data.shadowItMonthlySpend) }\n )}\n </p>\n </Card>\n </div>\n\n {/* Departments */}\n <Section\n title={tr('enterprise.departments.title', 'Spend by department')}\n description={tr(\n 'enterprise.departments.description',\n 'Monthly run-rate per department, with each department budget.'\n )}\n >\n <GlassCard treatment=\"plain\" className=\"p-6\" data-testid=\"enterprise-departments\">\n {data.departments.length ? (\n <div className=\"grid grid-cols-1 gap-6 lg:grid-cols-2\">\n <BarList\n data={data.departments.map((d) => ({\n label: departmentName(tr, d.department),\n value: d.monthlySpend,\n }))}\n valueFormat={fmt}\n />\n <ul className=\"divide-y divide-border-subtle\">\n {data.departments.map((d) => (\n <li key={d.department ?? '__unassigned'} className=\"py-3 first:pt-0\">\n <div className=\"flex items-center justify-between gap-3 text-sm\">\n <span className=\"truncate font-medium text-text-primary\">\n {departmentName(tr, d.department)}\n </span>\n <span className=\"shrink-0 tabular-nums text-text-secondary\">\n {tr('enterprise.departments.row', '{{count}} subs · {{share}}%', {\n count: d.relationshipCount,\n share: d.sharePct,\n })}\n </span>\n </div>\n {d.budget && (\n <div className=\"mt-2\">\n <BudgetMeter status={d.budget} fmt={fmt} tr={tr} />\n </div>\n )}\n </li>\n ))}\n </ul>\n </div>\n ) : (\n <p className=\"text-sm text-text-secondary\">\n {tr('enterprise.departments.none', 'No spend is billing right now.')}\n </p>\n )}\n </GlassCard>\n </Section>\n\n {/* Duplicates + shadow IT */}\n <div className=\"grid grid-cols-1 gap-4 lg:grid-cols-2\">\n <Card\n title={tr('enterprise.duplicates.title', 'Duplicate tools ({{count}})', {\n count: data.duplicateTools.length,\n })}\n icon={<Copy className=\"h-4 w-4\" />}\n testId=\"enterprise-duplicates\"\n >\n {data.duplicateTools.length ? (\n <ul className=\"space-y-3\">\n {data.duplicateTools.map((g) => (\n <li\n key={g.key}\n className=\"rounded-lg border border-status-warning-text/30 bg-status-warning-bg-subtle p-3\"\n >\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"min-w-0\">\n {g.providerId ? (\n <button\n type=\"button\"\n className={linkCls}\n onClick={() => navigateTo(`/providers/${g.providerId}`)}\n >\n {g.name}\n </button>\n ) : (\n <p className=\"truncate text-sm font-medium text-text-primary\">\n {g.name}\n </p>\n )}\n <p className=\"text-xs text-text-secondary\">\n {tr(\n 'enterprise.duplicates.summary',\n '{{count}} subscriptions · {{teams}}',\n {\n count: g.relationshipCount,\n teams: g.departments.length\n ? g.departments.join(', ')\n : tr('enterprise.departments.unassigned', 'Unassigned'),\n }\n )}\n </p>\n </div>\n <span className=\"shrink-0 text-right text-xs text-status-warning-text\">\n {tr('enterprise.duplicates.save', 'Save up to {{amount}}/mo', {\n amount: fmt(g.potentialMonthlySavings),\n })}\n </span>\n </div>\n <ul className=\"mt-2 flex flex-wrap gap-1.5\">\n {g.relationships.map((m) => (\n <li key={m.relationshipId}>\n <button\n type=\"button\"\n onClick={() => openRelationship(m.relationshipId)}\n className={cn(\n 'rounded-md px-2 py-1 text-xs hover:bg-bg-sunken',\n m.keep\n ? 'border border-status-success-text/40 bg-status-success-bg-subtle text-status-success-text'\n : 'bg-bg-surface text-text-primary'\n )}\n >\n {m.keep ? `${tr('enterprise.duplicates.keep', 'Keep')}: ` : ''}\n {m.label} · {fmt(m.monthlySpend)}\n </button>\n </li>\n ))}\n </ul>\n <p className=\"mt-2 text-xs text-text-secondary\">\n {tr(OVERLAP_TYPE[g.type].key, OVERLAP_TYPE[g.type].fallback)} ·{' '}\n {g.rationale}\n </p>\n </li>\n ))}\n </ul>\n ) : (\n <p className=\"text-sm text-text-secondary\">\n {tr('enterprise.duplicates.none', 'No duplicate or overlapping tools found.')}\n </p>\n )}\n </Card>\n\n <Card\n title={tr('enterprise.shadow.title', 'Shadow IT ({{count}})', {\n count: data.shadowItCount,\n })}\n icon={<EyeOff className=\"h-4 w-4\" />}\n testId=\"enterprise-shadow-it\"\n >\n {data.shadowIt.length ? (\n <ul className=\"divide-y divide-border-subtle\">\n {data.shadowIt.map((s) => (\n <li\n key={s.relationshipId}\n className=\"flex items-center justify-between gap-3 py-2.5\"\n >\n <div className=\"min-w-0\">\n <button\n type=\"button\"\n className={linkCls}\n onClick={() => openRelationship(s.relationshipId)}\n >\n {s.label}\n </button>\n <p className=\"text-xs text-text-secondary\">\n {tr(SHADOW_REASON[s.reason].key, SHADOW_REASON[s.reason].fallback)}\n {' · '}\n {departmentName(tr, s.department)}\n {' · '}\n {tr('enterprise.shadow.found', 'found {{date}}', {\n date: formatDate(s.detectedAt),\n })}\n </p>\n </div>\n <span className=\"shrink-0 text-sm font-semibold tabular-nums text-text-primary\">\n {fmt(s.monthlySpend)}\n </span>\n </li>\n ))}\n </ul>\n ) : (\n <p className=\"text-sm text-text-secondary\">\n {tr(\n 'enterprise.shadow.none',\n 'Every discovered tool is linked to an approved provider.'\n )}\n </p>\n )}\n </Card>\n </div>\n\n {/* Renewal pipeline */}\n <Section\n title={tr('enterprise.renewals.title', 'Renewal pipeline')}\n description={tr(\n 'enterprise.renewals.description',\n 'Renewal charges due by quarter, for planning renegotiations.'\n )}\n >\n <div\n className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4\"\n data-testid=\"enterprise-renewals\"\n >\n {data.renewalPipeline.map((q) => (\n <GlassCard key={q.label} treatment=\"plain\" className=\"p-5\">\n <p className=\"text-xs font-medium uppercase tracking-wide text-text-secondary\">\n {q.label}\n </p>\n <p className=\"mt-1 text-xl font-bold tabular-nums text-text-primary\">\n {fmt(q.amount)}\n </p>\n <p className=\"text-xs text-text-secondary\">\n {tr(\n 'enterprise.renewals.counts',\n '{{renewals}} renewals · {{subs}} subscriptions',\n {\n renewals: q.renewalCount,\n subs: q.relationshipCount,\n }\n )}\n </p>\n {q.topRenewals.length > 0 && (\n <ul className=\"mt-3 space-y-1.5\">\n {q.topRenewals.map((r) => (\n <li\n key={r.relationshipId}\n className=\"flex items-center justify-between gap-2\"\n >\n <button\n type=\"button\"\n className={cn(linkCls, 'text-xs')}\n onClick={() => openRelationship(r.relationshipId)}\n >\n {r.label}\n </button>\n <span className=\"shrink-0 text-xs tabular-nums text-text-secondary\">\n {formatDate(r.renewsAt)} · {fmt(r.amount)}\n </span>\n </li>\n ))}\n </ul>\n )}\n </GlassCard>\n ))}\n </div>\n </Section>\n\n {/* Risk flags */}\n <Section\n title={tr('enterprise.risks.title', 'Compliance and risk flags ({{count}})', {\n count: data.riskFlagCount,\n })}\n >\n <GlassCard treatment=\"plain\" className=\"p-2\" data-testid=\"enterprise-risks\">\n {data.riskFlags.length ? (\n <ul className=\"divide-y divide-border-subtle\">\n {data.riskFlags.map((f) => (\n <li\n key={`${f.kind}-${f.relationshipId}`}\n className=\"flex flex-col gap-2 p-4 sm:flex-row sm:items-center sm:justify-between\"\n >\n <div className=\"flex min-w-0 items-start gap-3\">\n <span\n className={cn(\n 'shrink-0 rounded-md px-2 py-0.5 text-xs font-medium',\n SEVERITY_STYLE[f.severity]\n )}\n >\n {tr(RISK_KIND[f.kind].key, RISK_KIND[f.kind].fallback)}\n </span>\n <div className=\"min-w-0\">\n <button\n type=\"button\"\n className={linkCls}\n onClick={() => openRelationship(f.relationshipId)}\n >\n {f.label}\n </button>\n <p className=\"text-xs text-text-secondary\">{f.detail}</p>\n </div>\n </div>\n <span className=\"shrink-0 text-xs text-text-secondary\">\n {departmentName(tr, f.department)} · {fmt(f.monthlySpend)}\n {tr('enterprise.perMonth', '/mo')}\n </span>\n </li>\n ))}\n </ul>\n ) : (\n <p className=\"flex items-center gap-2 p-4 text-sm text-text-secondary\">\n <ShieldAlert className=\"h-4 w-4\" aria-hidden />\n {tr('enterprise.risks.none', 'No compliance or risk issues found.')}\n </p>\n )}\n </GlassCard>\n </Section>\n\n {/* Licences */}\n <Section title={tr('enterprise.licenses.title', 'Licences')}>\n <GlassCard\n treatment=\"plain\"\n className=\"overflow-x-auto\"\n data-testid=\"enterprise-licenses\"\n >\n <table className=\"w-full min-w-[640px] text-sm\">\n <thead>\n <tr className=\"border-b border-border-subtle text-left text-xs text-text-secondary\">\n <th className=\"px-4 py-3 font-medium\">\n {tr('enterprise.licenses.app', 'App')}\n </th>\n <th className=\"px-4 py-3 font-medium\">\n {tr('enterprise.licenses.department', 'Department')}\n </th>\n <th className=\"px-4 py-3 font-medium\">\n {tr('enterprise.licenses.seats', 'Seats used / assigned')}\n </th>\n <th className=\"px-4 py-3 font-medium\">\n {tr('enterprise.licenses.utilization', 'Utilisation')}\n </th>\n <th className=\"px-4 py-3 text-right font-medium\">\n {tr('enterprise.licenses.annualCost', 'Annual cost')}\n </th>\n <th className=\"px-4 py-3 font-medium\">\n {tr('enterprise.licenses.renews', 'Renews')}\n </th>\n </tr>\n </thead>\n <tbody className=\"divide-y divide-border-subtle\">\n {data.licenses.map((l) => (\n <tr key={l.relationshipId}>\n <td className=\"max-w-[16rem] px-4 py-3\">\n <button\n type=\"button\"\n className={linkCls}\n onClick={() => openRelationship(l.relationshipId)}\n >\n {l.label}\n </button>\n {l.providerName && (\n <p className=\"truncate text-xs text-text-secondary\">{l.providerName}</p>\n )}\n </td>\n <td className=\"px-4 py-3 text-text-secondary\">\n {departmentName(tr, l.department)}\n </td>\n <td className=\"px-4 py-3 tabular-nums text-text-primary\">\n {l.seatsAssigned != null ? `${l.seatsUsed ?? 0} / ${l.seatsAssigned}` : '—'}\n </td>\n <td className=\"w-40 px-4 py-3\">\n {l.utilizationPct != null ? (\n <MiniProgress\n value={l.utilizationPct}\n tone={l.utilizationPct >= 70 ? 'positive' : 'warning'}\n />\n ) : (\n <span className=\"text-text-secondary\">—</span>\n )}\n </td>\n <td className=\"px-4 py-3 text-right font-semibold tabular-nums text-text-primary\">\n {fmt(l.annualCost)}\n </td>\n <td className=\"px-4 py-3 text-text-secondary\">\n {l.renewsAt ? formatDate(l.renewsAt) : '—'}\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n {data.licenses.length === 0 && (\n <p className=\"p-4 text-sm text-text-secondary\">\n {tr('enterprise.licenses.none', 'No subscription is billing right now.')}\n </p>\n )}\n </GlassCard>\n </Section>\n\n {data.otherCurrencies.length > 0 && (\n <p className=\"flex items-start gap-2 text-xs text-text-secondary\">\n <AlertTriangle className=\"mt-0.5 h-3.5 w-3.5 shrink-0\" aria-hidden />\n {tr(\n 'enterprise.otherCurrencies',\n 'Not included above (amounts are never converted): {{list}}. Switch currency to see them.',\n {\n list: data.otherCurrencies\n .map((c) => `${formatCurrency(c.monthlySpend, c.currency)}/mo`)\n .join(', '),\n }\n )}\n </p>\n )}\n </div>\n )}\n\n {budgetsOpen && (\n <BudgetsModal\n currency={data.currency}\n departments={departmentNames}\n onClose={() => setBudgetsOpen(false)}\n />\n )}\n </PageLayout>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"EnterpriseSpendPage.js","names":[],"sources":["../../../../src/subscriberbot/pages/enterprise/EnterpriseSpendPage.tsx"],"sourcesContent":["/**\n * Enterprise SaaS spend management\n * @module subscriberbot/pages/enterprise\n *\n * `/subscriberbot/enterprise/spend` (BOFF-4008) — the finance / IT leadership\n * view of recurring spend: company-wide spend and trend, budget variance,\n * spend by department, duplicate tools, shadow IT, the quarterly renewal\n * pipeline, compliance / risk flags and the licence table. Every figure comes\n * from the real `subEnterpriseSpendDashboard` query, which aggregates the\n * live portfolio server-side; nothing on this page is mocked.\n */\n\nimport { useMemo, useState, type FC, type ReactNode } from 'react';\nimport {\n AlertTriangle,\n Building2,\n CalendarClock,\n Copy,\n EyeOff,\n Layers,\n PiggyBank,\n ShieldAlert,\n Target,\n Users,\n Wallet,\n} from 'lucide-react';\nimport { GlassCard, PagePurpose } from '@burdenoff/fe-libs/ui';\nimport {\n BarList,\n EmptyState,\n ErrorView,\n LoadingView,\n MetricsCard,\n MiniProgress,\n Modal,\n PageLayout,\n Section,\n Sparkline,\n} from '../../components';\nimport { useSubscriberBot } from '../../context/SubscriberBotContext';\nimport {\n useEnterpriseSpendDashboard,\n useSetSpendBudget,\n useSpendBudgets,\n} from '../../hooks/useSubscriberBotApi';\nimport { useTr } from '../../hooks/useTr';\nimport { formatCurrency, formatDate } from '../../utils/formatters';\nimport { cn } from '../../utils/cn';\nimport type {\n SubBudgetStatus,\n SubEnterpriseSpendDashboard,\n SubOverlapType,\n SubShadowItReason,\n SubSpendRiskKind,\n SubSpendRiskSeverity,\n} from '../../types';\n\ntype Tr = ReturnType<typeof useTr>;\n\nconst SHADOW_REASON: Record<SubShadowItReason, { key: string; fallback: string }> = {\n UNCATALOGUED: { key: 'enterprise.shadow.reasons.uncatalogued', fallback: 'Not in the catalogue' },\n UNVERIFIED_PROVIDER: {\n key: 'enterprise.shadow.reasons.unverified',\n fallback: 'Unverified provider',\n },\n DEPRECATED_PROVIDER: {\n key: 'enterprise.shadow.reasons.deprecated',\n fallback: 'Deprecated provider',\n },\n};\n\nconst OVERLAP_TYPE: Record<SubOverlapType, { key: string; fallback: string }> = {\n EXACT_DUPLICATE: { key: 'enterprise.duplicates.types.exact', fallback: 'Same vendor' },\n CAPABILITY: { key: 'enterprise.duplicates.types.capability', fallback: 'Same capability' },\n ENTITLEMENT: {\n key: 'enterprise.duplicates.types.entitlement',\n fallback: 'Covered by another plan',\n },\n};\n\nconst RISK_KIND: Record<SubSpendRiskKind, { key: string; fallback: string }> = {\n PAST_DUE: { key: 'enterprise.risks.kinds.pastDue', fallback: 'Payment failed' },\n MISSING_INVOICE: { key: 'enterprise.risks.kinds.missingInvoice', fallback: 'Missing invoice' },\n UNTRACKED_TRIAL: { key: 'enterprise.risks.kinds.untrackedTrial', fallback: 'Untracked trial' },\n HIGH_COST_AI: { key: 'enterprise.risks.kinds.highCostAi', fallback: 'High-cost AI service' },\n};\n\nconst SEVERITY_STYLE: Record<SubSpendRiskSeverity, string> = {\n HIGH: 'bg-status-error-bg-subtle text-status-error-text',\n MEDIUM: 'bg-status-warning-bg-subtle text-status-warning-text',\n LOW: 'bg-bg-sunken text-text-secondary',\n};\n\nconst inputCls =\n 'w-full rounded-lg border border-border-subtle bg-bg-surface px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]';\nconst labelCls = 'mb-1.5 block text-xs font-medium text-text-secondary';\nconst linkCls =\n 'truncate text-left text-sm font-medium text-text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] rounded';\n\nfunction departmentName(tr: Tr, department: string | null): string {\n return department ?? tr('enterprise.departments.unassigned', 'Unassigned');\n}\n\n/** \"62% of 5,000.00 budget\" pill for a budget status. */\nconst BudgetMeter: FC<{ status: SubBudgetStatus; fmt: (n: number) => string; tr: Tr }> = ({\n status,\n fmt,\n tr,\n}) => (\n <div className=\"space-y-1.5\">\n <MiniProgress\n value={Math.min(status.utilizationPct, 100)}\n tone={status.overBudget ? 'negative' : status.utilizationPct >= 85 ? 'warning' : 'positive'}\n />\n <p\n className={cn(\n 'text-xs tabular-nums',\n status.overBudget ? 'text-status-error-text' : 'text-text-secondary'\n )}\n >\n {status.overBudget\n ? tr('enterprise.budget.over', '{{amount}} over a {{budget}} budget', {\n amount: fmt(-status.variance),\n budget: fmt(status.monthlyBudget),\n })\n : tr('enterprise.budget.under', '{{amount}} left of {{budget}} ({{pct}}% used)', {\n amount: fmt(status.variance),\n budget: fmt(status.monthlyBudget),\n pct: status.utilizationPct,\n })}\n </p>\n </div>\n);\n\nconst Card: FC<{ title: string; icon: ReactNode; children: ReactNode; testId?: string }> = ({\n title,\n icon,\n children,\n testId,\n}) => (\n <GlassCard treatment=\"plain\" className=\"p-6\" data-testid={testId}>\n <div className=\"mb-4 flex items-center gap-2\">\n <span className=\"text-text-secondary\">{icon}</span>\n <h3 className=\"text-sm font-semibold text-text-primary\">{title}</h3>\n </div>\n {children}\n </GlassCard>\n);\n\n/** Budgets editor — company-wide and per-department monthly budgets. */\nconst BudgetsModal: FC<{\n currency: string;\n departments: string[];\n onClose: () => void;\n}> = ({ currency, departments, onClose }) => {\n const tr = useTr();\n const budgetsQ = useSpendBudgets();\n const setBudget = useSetSpendBudget();\n const [department, setDepartment] = useState('');\n const [amount, setAmount] = useState('');\n const [error, setError] = useState<string | null>(null);\n\n const budgets = (budgetsQ.data ?? []).filter((b) => b.currency === currency);\n\n const save = (dept: string | null, monthlyAmount: number | null) => {\n setError(null);\n setBudget.mutate(\n { department: dept, currency, monthlyAmount },\n {\n onSuccess: () => {\n setAmount('');\n setDepartment('');\n },\n onError: (err) =>\n setError(\n err instanceof Error\n ? err.message\n : tr('enterprise.budgets.error', 'Could not save the budget.')\n ),\n }\n );\n };\n\n const handleSubmit = (e: React.FormEvent) => {\n e.preventDefault();\n const value = Number(amount);\n if (!amount.trim() || !Number.isFinite(value) || value <= 0) {\n setError(tr('enterprise.budgets.amountRequired', 'Enter a monthly amount above zero.'));\n return;\n }\n save(department.trim() || null, value);\n };\n\n return (\n <Modal\n title={tr('enterprise.budgets.title', 'Monthly budgets')}\n description={tr(\n 'enterprise.budgets.description',\n 'Budgets apply to spend billed in {{currency}}. Leave the department empty for the company-wide budget.',\n { currency }\n )}\n onClose={onClose}\n maxWidthClassName=\"max-w-xl\"\n >\n <form onSubmit={handleSubmit} className=\"space-y-4\">\n {error && (\n <div className=\"rounded-lg border border-status-error-text/30 bg-status-error-bg-subtle px-4 py-2 text-sm text-status-error-text\">\n {error}\n </div>\n )}\n <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-[1fr_10rem_auto] sm:items-end\">\n <div>\n <label htmlFor=\"budget-department\" className={labelCls}>\n {tr('enterprise.budgets.department', 'Department (optional)')}\n </label>\n <input\n id=\"budget-department\"\n list=\"budget-department-options\"\n value={department}\n maxLength={120}\n onChange={(e) => setDepartment(e.target.value)}\n placeholder={tr('enterprise.budgets.companyWide', 'Company-wide')}\n className={inputCls}\n />\n <datalist id=\"budget-department-options\">\n {departments.map((d) => (\n <option key={d} value={d} />\n ))}\n </datalist>\n </div>\n <div>\n <label htmlFor=\"budget-amount\" className={labelCls}>\n {tr('enterprise.budgets.amount', 'Per month ({{currency}})', { currency })}\n </label>\n <input\n id=\"budget-amount\"\n type=\"number\"\n min=\"0\"\n step=\"0.01\"\n inputMode=\"decimal\"\n value={amount}\n onChange={(e) => setAmount(e.target.value)}\n className={inputCls}\n />\n </div>\n <button\n type=\"submit\"\n disabled={setBudget.isPending}\n className=\"rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text hover:opacity-90 disabled:opacity-60\"\n >\n {tr('enterprise.budgets.save', 'Save budget')}\n </button>\n </div>\n </form>\n\n <div className=\"mt-6\">\n {budgetsQ.isLoading ? (\n <LoadingView label={tr('enterprise.budgets.loading', 'Loading budgets…')} />\n ) : budgets.length === 0 ? (\n <p className=\"text-sm text-text-secondary\">\n {tr('enterprise.budgets.none', 'No budgets set for {{currency}} yet.', { currency })}\n </p>\n ) : (\n <ul className=\"divide-y divide-border-subtle rounded-lg border border-border-subtle\">\n {budgets.map((b) => (\n <li key={b.id} className=\"flex items-center justify-between gap-3 px-4 py-3\">\n <span className=\"truncate text-sm text-text-primary\">\n {b.department ?? tr('enterprise.budgets.companyWide', 'Company-wide')}\n </span>\n <span className=\"flex items-center gap-3\">\n <span className=\"text-sm font-semibold tabular-nums text-text-primary\">\n {formatCurrency(b.monthlyAmount, b.currency)}\n </span>\n <button\n type=\"button\"\n onClick={() => save(b.department, null)}\n disabled={setBudget.isPending}\n aria-label={tr('enterprise.budgets.removeAria', 'Remove the {{name}} budget', {\n name: b.department ?? tr('enterprise.budgets.companyWide', 'Company-wide'),\n })}\n className=\"text-xs font-medium text-status-error-text hover:underline disabled:opacity-60\"\n >\n {tr('enterprise.budgets.remove', 'Remove')}\n </button>\n </span>\n </li>\n ))}\n </ul>\n )}\n </div>\n </Modal>\n );\n};\n\nexport function EnterpriseSpendPage() {\n const tr = useTr();\n const { navigateTo } = useSubscriberBot();\n const [currency, setCurrency] = useState<string | null>(null);\n const [budgetsOpen, setBudgetsOpen] = useState(false);\n const query = useEnterpriseSpendDashboard(currency);\n const data: SubEnterpriseSpendDashboard | undefined = query.data;\n\n const fmt = useMemo(() => {\n const code = data?.currency ?? 'USD';\n return (n: number) => formatCurrency(n, code);\n }, [data?.currency]);\n\n const openRelationship = (id: string) => navigateTo(`/relationships/${id}`);\n\n if (query.isLoading)\n return <LoadingView label={tr('enterprise.loading', 'Aggregating company-wide spend…')} />;\n if (query.isError || !data)\n return (\n <ErrorView\n message={tr('enterprise.error', 'Could not load the enterprise spend dashboard.')}\n onRetry={() => void query.refetch()}\n />\n );\n\n const nextQuarter = data.renewalPipeline[0];\n const change = data.monthlySpendChangePct;\n const currencyOptions = [data.currency, ...data.otherCurrencies.map((c) => c.currency)];\n const departmentNames = data.departments.map((d) => d.department).filter((d): d is string => !!d);\n const isEmpty =\n data.activeRelationshipCount === 0 &&\n data.shadowItCount === 0 &&\n data.riskFlagCount === 0 &&\n data.departments.length === 0;\n\n return (\n <PageLayout\n title={tr('enterprise.title', 'SaaS spend management')}\n description={tr(\n 'enterprise.description',\n 'Company-wide recurring spend by department, with duplicate tools, shadow IT, renewals and risks.'\n )}\n actions={\n <div className=\"flex items-center gap-2\">\n {currencyOptions.length > 1 && (\n <label className=\"flex items-center gap-2 text-xs text-text-secondary\">\n {tr('enterprise.currency', 'Currency')}\n <select\n aria-label={tr('enterprise.currency', 'Currency')}\n value={data.currency}\n onChange={(e) => setCurrency(e.target.value)}\n className=\"rounded-lg border border-border-subtle bg-bg-surface px-2 py-1.5 text-xs text-text-primary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n >\n {currencyOptions.map((c) => (\n <option key={c} value={c}>\n {c}\n </option>\n ))}\n </select>\n </label>\n )}\n <button\n type=\"button\"\n onClick={() => setBudgetsOpen(true)}\n className=\"inline-flex items-center gap-2 rounded-lg border border-border-subtle px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-sunken\"\n >\n <Target className=\"h-4 w-4\" aria-hidden /> {tr('enterprise.manageBudgets', 'Budgets')}\n </button>\n </div>\n }\n >\n <PagePurpose className=\"mb-6\">\n {tr(\n 'enterprise.purpose',\n 'For finance, IT and procurement. Spend is the monthly run-rate of every subscription billing right now, computed live from your portfolio in one currency at a time. Allocate subscriptions to a department on the subscription itself.'\n )}\n </PagePurpose>\n\n {data.truncated && (\n <p className=\"mb-4 rounded-lg bg-status-warning-bg-subtle px-4 py-2 text-sm text-status-warning-text\">\n {tr(\n 'enterprise.truncated',\n 'This workspace has more subscriptions than one dashboard aggregates; figures cover the most recent ones.'\n )}\n </p>\n )}\n\n {isEmpty ? (\n <EmptyState\n icon={<Building2 className=\"h-8 w-8\" />}\n title={tr('enterprise.emptyTitle', 'No company spend to analyse yet')}\n description={tr(\n 'enterprise.emptyDescription',\n 'Add your SaaS subscriptions and allocate them to departments to see spend, duplicates, shadow IT and renewals here.'\n )}\n action={\n <button\n type=\"button\"\n onClick={() => navigateTo('/relationships/new')}\n className=\"rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text hover:opacity-90\"\n >\n {tr('enterprise.emptyAction', 'Add a subscription')}\n </button>\n }\n />\n ) : (\n <div className=\"space-y-6\">\n {/* KPI header */}\n <div\n className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4\"\n data-testid=\"enterprise-kpis\"\n >\n <MetricsCard\n featured\n icon={<Wallet className=\"h-5 w-5\" />}\n value={fmt(data.annualSpend)}\n label={tr(\n 'enterprise.kpis.annualSpend',\n 'Total SaaS spend / year ({{monthly}} / month)',\n {\n monthly: fmt(data.monthlySpend),\n }\n )}\n trend={\n change == null ? undefined : change > 0 ? 'up' : change < 0 ? 'down' : 'neutral'\n }\n trendValue={change == null ? undefined : `${change > 0 ? '+' : ''}${change}%`}\n />\n <MetricsCard\n icon={<Layers className=\"h-5 w-5\" />}\n value={data.activeRelationshipCount}\n label={tr('enterprise.kpis.activeLicenses', 'Active licences')}\n />\n <MetricsCard\n icon={<Users className=\"h-5 w-5\" />}\n value={data.seatUtilizationPct != null ? `${data.seatUtilizationPct}%` : '—'}\n label={\n data.seatUtilizationPct != null\n ? tr('enterprise.kpis.seats', 'Seats utilised ({{used}} of {{assigned}})', {\n used: data.seatsUsed,\n assigned: data.seatsAssigned,\n })\n : tr('enterprise.kpis.seatsUntracked', 'Seats utilised (no seat data yet)')\n }\n />\n <MetricsCard\n icon={<CalendarClock className=\"h-5 w-5\" />}\n value={nextQuarter ? nextQuarter.relationshipCount : 0}\n label={tr('enterprise.kpis.renewals', 'Renewals this quarter ({{amount}})', {\n amount: fmt(nextQuarter?.amount ?? 0),\n })}\n />\n </div>\n\n {/* Trend + budget */}\n <div className=\"grid grid-cols-1 gap-4 lg:grid-cols-3\">\n <Card\n title={tr('enterprise.trend.title', 'Monthly run-rate, last 150 days')}\n icon={<Wallet className=\"h-4 w-4\" />}\n >\n <Sparkline\n points={data.trend.map((p) => p.monthlySpend)}\n positive={(change ?? 0) <= 0}\n className=\"w-full\"\n />\n <p className=\"mt-2 text-xs text-text-secondary\">\n {tr('enterprise.trend.vs', '{{now}} now vs {{before}} 30 days ago', {\n now: fmt(data.monthlySpend),\n before: fmt(data.previousMonthlySpend),\n })}\n </p>\n </Card>\n <Card\n title={tr('enterprise.budget.title', 'Company budget')}\n icon={<Target className=\"h-4 w-4\" />}\n testId=\"enterprise-budget\"\n >\n {data.budget ? (\n <BudgetMeter status={data.budget} fmt={fmt} tr={tr} />\n ) : (\n <div className=\"space-y-3\">\n <p className=\"text-sm text-text-secondary\">\n {tr('enterprise.budget.none', 'No company-wide budget set for {{currency}}.', {\n currency: data.currency,\n })}\n </p>\n <button\n type=\"button\"\n onClick={() => setBudgetsOpen(true)}\n className=\"text-sm font-medium text-text-primary underline\"\n >\n {tr('enterprise.budget.set', 'Set a budget')}\n </button>\n </div>\n )}\n </Card>\n <Card\n title={tr('enterprise.savings.title', 'Consolidation opportunity')}\n icon={<PiggyBank className=\"h-4 w-4\" />}\n >\n <p className=\"text-2xl font-bold tabular-nums text-text-primary\">\n {fmt(data.duplicatePotentialMonthlySavings)}\n <span className=\"text-sm font-normal text-text-secondary\">\n {tr('enterprise.perMonth', '/mo')}\n </span>\n </p>\n <p className=\"mt-1 text-xs text-text-secondary\">\n {tr(\n 'enterprise.savings.detail',\n 'Upper bound if each duplicated tool is consolidated into one subscription; shadow IT costs {{shadow}} / month.',\n { shadow: fmt(data.shadowItMonthlySpend) }\n )}\n </p>\n </Card>\n </div>\n\n {/* Departments */}\n <Section\n title={tr('enterprise.departments.title', 'Spend by department')}\n description={tr(\n 'enterprise.departments.description',\n 'Monthly run-rate per department, with each department budget.'\n )}\n >\n <GlassCard treatment=\"plain\" className=\"p-6\" data-testid=\"enterprise-departments\">\n {data.departments.length ? (\n <div className=\"grid grid-cols-1 gap-6 lg:grid-cols-2\">\n <BarList\n data={data.departments.map((d) => ({\n label: departmentName(tr, d.department),\n value: d.monthlySpend,\n }))}\n valueFormat={fmt}\n />\n <ul className=\"divide-y divide-border-subtle\">\n {data.departments.map((d) => (\n <li key={d.department ?? '__unassigned'} className=\"py-3 first:pt-0\">\n <div className=\"flex items-center justify-between gap-3 text-sm\">\n <span className=\"truncate font-medium text-text-primary\">\n {departmentName(tr, d.department)}\n </span>\n <span className=\"shrink-0 tabular-nums text-text-secondary\">\n {tr('enterprise.departments.row', '{{count}} subs · {{share}}%', {\n count: d.relationshipCount,\n share: d.sharePct,\n })}\n </span>\n </div>\n {d.budget && (\n <div className=\"mt-2\">\n <BudgetMeter status={d.budget} fmt={fmt} tr={tr} />\n </div>\n )}\n </li>\n ))}\n </ul>\n </div>\n ) : (\n <p className=\"text-sm text-text-secondary\">\n {tr('enterprise.departments.none', 'No spend is billing right now.')}\n </p>\n )}\n </GlassCard>\n </Section>\n\n {/* Duplicates + shadow IT */}\n <div className=\"grid grid-cols-1 gap-4 lg:grid-cols-2\">\n <Card\n title={tr('enterprise.duplicates.title', 'Duplicate tools ({{count}})', {\n count: data.duplicateTools.length,\n })}\n icon={<Copy className=\"h-4 w-4\" />}\n testId=\"enterprise-duplicates\"\n >\n {data.duplicateTools.length ? (\n <ul className=\"space-y-3\">\n {data.duplicateTools.map((g) => (\n <li\n key={g.key}\n className=\"rounded-lg border border-status-warning-text/30 bg-status-warning-bg-subtle p-3\"\n >\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"min-w-0\">\n {g.providerId ? (\n <button\n type=\"button\"\n className={linkCls}\n onClick={() => navigateTo(`/providers/${g.providerId}`)}\n >\n {g.name}\n </button>\n ) : (\n <p className=\"truncate text-sm font-medium text-text-primary\">\n {g.name}\n </p>\n )}\n <p className=\"text-xs text-text-secondary\">\n {tr(\n 'enterprise.duplicates.summary',\n '{{count}} subscriptions · {{teams}}',\n {\n count: g.relationshipCount,\n teams: g.departments.length\n ? g.departments.join(', ')\n : tr('enterprise.departments.unassigned', 'Unassigned'),\n }\n )}\n </p>\n </div>\n <span className=\"shrink-0 text-right text-xs text-status-warning-text\">\n {tr('enterprise.duplicates.save', 'Save up to {{amount}}/mo', {\n amount: fmt(g.potentialMonthlySavings),\n })}\n </span>\n </div>\n <ul className=\"mt-2 flex flex-wrap gap-1.5\">\n {g.relationships.map((m) => (\n <li key={m.relationshipId}>\n <button\n type=\"button\"\n onClick={() => openRelationship(m.relationshipId)}\n className={cn(\n 'rounded-md px-2 py-1 text-xs hover:bg-bg-sunken',\n m.keep\n ? 'border border-status-success-text/40 bg-status-success-bg-subtle text-status-success-text'\n : 'bg-bg-surface text-text-primary'\n )}\n >\n {m.keep ? `${tr('enterprise.duplicates.keep', 'Keep')}: ` : ''}\n {m.label} · {fmt(m.monthlySpend)}\n </button>\n </li>\n ))}\n </ul>\n <p className=\"mt-2 text-xs text-text-secondary\">\n {tr(OVERLAP_TYPE[g.type].key, OVERLAP_TYPE[g.type].fallback)} ·{' '}\n {g.rationale}\n </p>\n </li>\n ))}\n </ul>\n ) : (\n <p className=\"text-sm text-text-secondary\">\n {tr('enterprise.duplicates.none', 'No duplicate or overlapping tools found.')}\n </p>\n )}\n </Card>\n\n <Card\n title={tr('enterprise.shadow.title', 'Shadow IT ({{count}})', {\n count: data.shadowItCount,\n })}\n icon={<EyeOff className=\"h-4 w-4\" />}\n testId=\"enterprise-shadow-it\"\n >\n {data.shadowIt.length ? (\n <ul className=\"divide-y divide-border-subtle\">\n {data.shadowIt.map((s) => (\n <li\n key={s.relationshipId}\n className=\"flex items-center justify-between gap-3 py-2.5\"\n >\n <div className=\"min-w-0\">\n <button\n type=\"button\"\n className={linkCls}\n onClick={() => openRelationship(s.relationshipId)}\n >\n {s.label}\n </button>\n <p className=\"text-xs text-text-secondary\">\n {tr(SHADOW_REASON[s.reason].key, SHADOW_REASON[s.reason].fallback)}\n {' · '}\n {departmentName(tr, s.department)}\n {' · '}\n {tr('enterprise.shadow.found', 'found {{date}}', {\n date: formatDate(s.detectedAt),\n })}\n </p>\n </div>\n <span className=\"shrink-0 text-sm font-semibold tabular-nums text-text-primary\">\n {fmt(s.monthlySpend)}\n </span>\n </li>\n ))}\n </ul>\n ) : (\n <p className=\"text-sm text-text-secondary\">\n {tr(\n 'enterprise.shadow.none',\n 'Every discovered tool is linked to an approved provider.'\n )}\n </p>\n )}\n </Card>\n </div>\n\n {/* Renewal pipeline */}\n <Section\n title={tr('enterprise.renewals.title', 'Renewal pipeline')}\n description={tr(\n 'enterprise.renewals.description',\n 'Renewal charges due by quarter, for planning renegotiations.'\n )}\n >\n <div\n className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4\"\n data-testid=\"enterprise-renewals\"\n >\n {data.renewalPipeline.map((q) => (\n <GlassCard key={q.label} treatment=\"plain\" className=\"p-5\">\n <p className=\"text-xs font-medium uppercase tracking-wide text-text-secondary\">\n {q.label}\n </p>\n <p className=\"mt-1 text-xl font-bold tabular-nums text-text-primary\">\n {fmt(q.amount)}\n </p>\n <p className=\"text-xs text-text-secondary\">\n {tr(\n 'enterprise.renewals.counts',\n '{{renewals}} renewals · {{subs}} subscriptions',\n {\n renewals: q.renewalCount,\n subs: q.relationshipCount,\n }\n )}\n </p>\n {q.topRenewals.length > 0 && (\n <ul className=\"mt-3 space-y-1.5\">\n {q.topRenewals.map((r) => (\n <li\n key={r.relationshipId}\n className=\"flex items-center justify-between gap-2\"\n >\n <button\n type=\"button\"\n className={cn(linkCls, 'text-xs')}\n onClick={() => openRelationship(r.relationshipId)}\n >\n {r.label}\n </button>\n <span className=\"shrink-0 text-xs tabular-nums text-text-secondary\">\n {formatDate(r.renewsAt)} · {fmt(r.amount)}\n </span>\n </li>\n ))}\n </ul>\n )}\n </GlassCard>\n ))}\n </div>\n </Section>\n\n {/* Risk flags */}\n <Section\n title={tr('enterprise.risks.title', 'Compliance and risk flags ({{count}})', {\n count: data.riskFlagCount,\n })}\n >\n <GlassCard treatment=\"plain\" className=\"p-2\" data-testid=\"enterprise-risks\">\n {data.riskFlags.length ? (\n <ul className=\"divide-y divide-border-subtle\">\n {data.riskFlags.map((f) => (\n <li\n key={`${f.kind}-${f.relationshipId}`}\n className=\"flex flex-col gap-2 p-4 sm:flex-row sm:items-center sm:justify-between\"\n >\n <div className=\"flex min-w-0 items-start gap-3\">\n <span\n className={cn(\n 'shrink-0 rounded-md px-2 py-0.5 text-xs font-medium',\n SEVERITY_STYLE[f.severity]\n )}\n >\n {tr(RISK_KIND[f.kind].key, RISK_KIND[f.kind].fallback)}\n </span>\n <div className=\"min-w-0\">\n <button\n type=\"button\"\n className={linkCls}\n onClick={() => openRelationship(f.relationshipId)}\n >\n {f.label}\n </button>\n <p className=\"text-xs text-text-secondary\">{f.detail}</p>\n </div>\n </div>\n <span className=\"shrink-0 text-xs text-text-secondary\">\n {departmentName(tr, f.department)} · {fmt(f.monthlySpend)}\n {tr('enterprise.perMonth', '/mo')}\n </span>\n </li>\n ))}\n </ul>\n ) : (\n <p className=\"flex items-center gap-2 p-4 text-sm text-text-secondary\">\n <ShieldAlert className=\"h-4 w-4\" aria-hidden />\n {tr('enterprise.risks.none', 'No compliance or risk issues found.')}\n </p>\n )}\n </GlassCard>\n </Section>\n\n {/* Licences */}\n <Section title={tr('enterprise.licenses.title', 'Licences')}>\n <GlassCard\n treatment=\"plain\"\n className=\"overflow-x-auto\"\n data-testid=\"enterprise-licenses\"\n >\n <table className=\"w-full min-w-[640px] text-sm\">\n <thead>\n <tr className=\"border-b border-border-subtle text-left text-xs text-text-secondary\">\n <th className=\"px-4 py-3 font-medium\">\n {tr('enterprise.licenses.app', 'App')}\n </th>\n <th className=\"px-4 py-3 font-medium\">\n {tr('enterprise.licenses.department', 'Department')}\n </th>\n <th className=\"px-4 py-3 font-medium\">\n {tr('enterprise.licenses.seats', 'Seats used / assigned')}\n </th>\n <th className=\"px-4 py-3 font-medium\">\n {tr('enterprise.licenses.utilization', 'Utilisation')}\n </th>\n <th className=\"px-4 py-3 text-right font-medium\">\n {tr('enterprise.licenses.annualCost', 'Annual cost')}\n </th>\n <th className=\"px-4 py-3 font-medium\">\n {tr('enterprise.licenses.renews', 'Renews')}\n </th>\n </tr>\n </thead>\n <tbody className=\"divide-y divide-border-subtle\">\n {data.licenses.map((l) => (\n <tr key={l.relationshipId}>\n <td className=\"max-w-[16rem] px-4 py-3\">\n <button\n type=\"button\"\n className={linkCls}\n onClick={() => openRelationship(l.relationshipId)}\n >\n {l.label}\n </button>\n {l.providerName && (\n <p className=\"truncate text-xs text-text-secondary\">{l.providerName}</p>\n )}\n </td>\n <td className=\"px-4 py-3 text-text-secondary\">\n {departmentName(tr, l.department)}\n </td>\n <td className=\"px-4 py-3 tabular-nums text-text-primary\">\n {l.seatsAssigned != null ? `${l.seatsUsed ?? 0} / ${l.seatsAssigned}` : '—'}\n </td>\n <td className=\"w-40 px-4 py-3\">\n {l.utilizationPct != null ? (\n <MiniProgress\n value={l.utilizationPct}\n tone={l.utilizationPct >= 70 ? 'positive' : 'warning'}\n />\n ) : (\n <span className=\"text-text-secondary\">—</span>\n )}\n </td>\n <td className=\"px-4 py-3 text-right font-semibold tabular-nums text-text-primary\">\n {fmt(l.annualCost)}\n </td>\n <td className=\"px-4 py-3 text-text-secondary\">\n {l.renewsAt ? formatDate(l.renewsAt) : '—'}\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n {data.licenses.length === 0 && (\n <p className=\"p-4 text-sm text-text-secondary\">\n {tr('enterprise.licenses.none', 'No subscription is billing right now.')}\n </p>\n )}\n </GlassCard>\n </Section>\n\n {data.otherCurrencies.length > 0 && (\n <p className=\"flex items-start gap-2 text-xs text-text-secondary\">\n <AlertTriangle className=\"mt-0.5 h-3.5 w-3.5 shrink-0\" aria-hidden />\n {tr(\n 'enterprise.otherCurrencies',\n 'Not included above (amounts are never converted): {{list}}. Switch currency to see them.',\n {\n list: data.otherCurrencies\n .map((c) => `${formatCurrency(c.monthlySpend, c.currency)}/mo`)\n .join(', '),\n }\n )}\n </p>\n )}\n </div>\n )}\n\n {budgetsOpen && (\n <BudgetsModal\n currency={data.currency}\n departments={departmentNames}\n onClose={() => setBudgetsOpen(false)}\n />\n )}\n </PageLayout>\n );\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"ProvidersListPage.d.ts","sourceRoot":"","sources":["../../../../src/subscriberbot/pages/providers/ProvidersListPage.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAoKH,wBAAgB,iBAAiB,gCA8PhC"}
1
+ {"version":3,"file":"ProvidersListPage.d.ts","sourceRoot":"","sources":["../../../../src/subscriberbot/pages/providers/ProvidersListPage.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAoKH,wBAAgB,iBAAiB,gCA+PhC"}
@@ -196,6 +196,7 @@ function M() {
196
196
  /* @__PURE__ */ x(_, { className: "absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary" }),
197
197
  /* @__PURE__ */ x("input", {
198
198
  type: "text",
199
+ "aria-label": i("providers.searchLabel", "Search providers"),
199
200
  placeholder: i("providers.searchPlaceholder", "Search providers by name…"),
200
201
  value: M,
201
202
  onChange: (e) => N(e.target.value),
@@ -1 +1 @@
1
- {"version":3,"file":"ProvidersListPage.js","names":[],"sources":["../../../../src/subscriberbot/pages/providers/ProvidersListPage.tsx"],"sourcesContent":["/**\n * Providers list (Global Provider Registry)\n * @module subscriberbot/pages/providers\n *\n * Discovery marketplace: search and browse the provider registry. Server-side\n * search + category/status filters + pagination via `subListProviders`\n * (a SubProviderConnection). Category filter chips, a responsive grid of glass\n * provider cards with a real vendor-health indicator + churn hint, and a small\n * live summary strip computed from the fetched page.\n */\n\nimport { useState } from 'react';\nimport {\n Plus,\n Search,\n Building2,\n ShieldCheck,\n ChevronRight,\n Store,\n Activity,\n BadgeCheck,\n} from 'lucide-react';\nimport { EmphasisPanel, PagePurpose, GlassCard } from '@burdenoff/fe-libs/ui';\nimport { PageLayout } from '../../components/PageLayout';\nimport { StatusBadge } from '../../components/StatusBadge';\nimport { EmptyState } from '../../components/EmptyState';\nimport { LoadingView, ErrorView } from '../../components/StateViews';\nimport { MiniProgress } from '../../components';\nimport { useSubscriberBot } from '../../context/SubscriberBotContext';\nimport { useProviders } from '../../hooks/useSubscriberBotApi';\nimport { useTr } from '../../hooks/useTr';\nimport { cn } from '../../utils/cn';\nimport { formatEnumLabel } from '../../utils/formatters';\nimport type { SubProvider, SubProviderCategory, SubProviderStatus } from '../../types';\n\nconst PAGE_SIZE = 24;\n\nconst CATEGORY_KEY: Record<SubProviderCategory, string> = {\n SAAS: 'providers.categories.saas',\n AI_SERVICE: 'providers.categories.aiService',\n STREAMING: 'providers.categories.streaming',\n NEWSLETTER: 'providers.categories.newsletter',\n NEWS_DIGEST: 'providers.categories.newsDigest',\n MEMBERSHIP: 'providers.categories.membership',\n COMMUNITY: 'providers.categories.community',\n EDUCATION: 'providers.categories.education',\n FINANCIAL: 'providers.categories.financial',\n INSURANCE: 'providers.categories.insurance',\n TELECOM: 'providers.categories.telecom',\n UTILITY: 'providers.categories.utility',\n ASSOCIATION: 'providers.categories.association',\n LICENSE: 'providers.categories.license',\n CONTRACT: 'providers.categories.contract',\n OTHER: 'providers.categories.other',\n};\n\nconst STATUS_KEY: Record<SubProviderStatus, string> = {\n VERIFIED: 'providers.filters.verified',\n CLAIMED: 'providers.filters.claimed',\n UNVERIFIED: 'providers.filters.unverified',\n DEPRECATED: 'providers.filters.deprecated',\n};\n\n/** Map a 0–100 vendor-health score to a MiniProgress tone. Higher is healthier. */\nfunction healthTone(score: number): 'positive' | 'accent' | 'warning' | 'negative' {\n if (score >= 70) return 'positive';\n if (score >= 45) return 'accent';\n if (score >= 25) return 'warning';\n return 'negative';\n}\n\n/** Map a 0–100 churn-risk score to a label + semantic tone. Higher is worse. */\nfunction churnMeta(score: number, tr: ReturnType<typeof useTr>): { label: string; cls: string } {\n if (score >= 67)\n return {\n label: tr('providers.card.highChurn', 'High churn risk'),\n cls: 'bg-status-error-bg-subtle text-status-error-text',\n };\n if (score >= 34)\n return {\n label: tr('providers.card.moderateChurn', 'Moderate churn risk'),\n cls: 'bg-status-warning-bg-subtle text-status-warning-text',\n };\n return {\n label: tr('providers.card.lowChurn', 'Low churn risk'),\n cls: 'bg-status-success-bg-subtle text-status-success-text',\n };\n}\n\nfunction ProviderCard({ provider, onOpen }: { provider: SubProvider; onOpen: () => void }) {\n const tr = useTr();\n const health = provider.vendorHealthScore;\n const churn = provider.churnRiskScore;\n const churnInfo = churn != null ? churnMeta(churn, tr) : null;\n\n return (\n <GlassCard\n treatment=\"glass\"\n role=\"button\"\n tabIndex={0}\n onClick={onOpen}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n onOpen();\n }\n }}\n aria-label={tr('providers.card.openAria', 'Open {{name}}', { name: provider.name })}\n className=\"group flex cursor-pointer flex-col p-5 outline-none transition-all hover:-translate-y-0.5 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]\"\n >\n <div className=\"flex items-start justify-between gap-2\">\n <span className=\"flex size-10 items-center justify-center rounded-xl bg-[var(--color-accent-soft)] text-text-primary\">\n <Building2 className=\"h-5 w-5\" />\n </span>\n <StatusBadge status={provider.status} />\n </div>\n\n <p className=\"mt-3 truncate text-base font-semibold text-text-primary\">{provider.name}</p>\n <span className=\"mt-1 inline-flex w-fit rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\">\n {tr(CATEGORY_KEY[provider.category], formatEnumLabel(provider.category))}\n </span>\n\n {provider.description ? (\n <p className=\"mt-3 line-clamp-2 min-h-[2.5rem] text-xs leading-relaxed text-text-secondary\">\n {provider.description}\n </p>\n ) : (\n <p className=\"mt-3 min-h-[2.5rem] text-xs italic text-text-secondary/70\">\n {tr('providers.card.noDescription', 'No description yet.')}\n </p>\n )}\n\n <div className=\"mt-4 border-t border-border-subtle pt-3\">\n <div className=\"flex items-center justify-between text-xs\">\n <span className=\"inline-flex items-center gap-1 text-text-secondary\">\n <ShieldCheck className=\"h-3.5 w-3.5\" />{' '}\n {tr('providers.card.vendorHealth', 'Vendor health')}\n </span>\n <span className=\"font-semibold tabular-nums text-text-primary\">\n {health != null ? `${health}/100` : '—'}\n </span>\n </div>\n <MiniProgress\n value={health ?? 0}\n tone={health != null ? healthTone(health) : 'accent'}\n className=\"mt-1.5\"\n />\n </div>\n\n <div className=\"mt-3 flex flex-wrap items-center justify-between gap-2\">\n <div className=\"flex flex-wrap items-center gap-2\">\n {churnInfo && (\n <span\n className={cn(\n 'inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium',\n churnInfo.cls\n )}\n >\n {churnInfo.label}\n </span>\n )}\n {provider.region && (\n <span className=\"text-[11px] text-text-secondary\">{provider.region}</span>\n )}\n </div>\n <span className=\"inline-flex items-center gap-0.5 text-xs font-medium text-text-link opacity-0 transition-opacity group-hover:opacity-100\">\n {tr('providers.card.view', 'View')} <ChevronRight className=\"h-3.5 w-3.5\" />\n </span>\n </div>\n </GlassCard>\n );\n}\n\nexport function ProvidersListPage() {\n const tr = useTr();\n const { navigateTo } = useSubscriberBot();\n const [page, setPage] = useState(0);\n const [search, setSearch] = useState('');\n const [searchInput, setSearchInput] = useState('');\n const [category, setCategory] = useState<'' | SubProviderCategory>('');\n const [status, setStatus] = useState<'' | SubProviderStatus>('');\n\n const categoryOptions: Array<{ value: '' | SubProviderCategory; label: string }> = [\n { value: '', label: tr('providers.categories.all', 'All') },\n ...Object.entries(CATEGORY_KEY).map(([value, key]) => ({\n value: value as SubProviderCategory,\n label: tr(key, formatEnumLabel(value as SubProviderCategory)),\n })),\n ];\n\n const statusOptions: Array<{ value: '' | SubProviderStatus; label: string }> = [\n { value: '', label: tr('providers.filters.anyStatus', 'Any status') },\n ...Object.entries(STATUS_KEY).map(([value, key]) => ({\n value: value as SubProviderStatus,\n label: tr(key, formatEnumLabel(value as SubProviderStatus)),\n })),\n ];\n\n const { data, isLoading, isError, refetch, isFetching } = useProviders({\n search: search || undefined,\n category: category || undefined,\n status: status || undefined,\n pagination: { limit: PAGE_SIZE, offset: page * PAGE_SIZE },\n });\n\n const items = data?.items ?? [];\n const hasMore = data?.pageInfo.hasMore ?? false;\n\n // Live summary strip — computed from the fetched page (never fabricated).\n const verifiedCount = items.filter((p) => p.status === 'VERIFIED').length;\n const healthScores = items.map((p) => p.vendorHealthScore).filter((s): s is number => s != null);\n const avgHealth = healthScores.length\n ? Math.round(healthScores.reduce((a, b) => a + b, 0) / healthScores.length)\n : null;\n\n const submitSearch = (e: React.FormEvent) => {\n e.preventDefault();\n setSearch(searchInput.trim());\n setPage(0);\n };\n\n const summary: Array<{ icon: typeof Store; label: string; value: string }> = [\n {\n icon: Store,\n label: tr('providers.summary.onThisPage', 'On this page'),\n value: String(items.length),\n },\n {\n icon: BadgeCheck,\n label: tr('providers.filters.verified', 'Verified'),\n value: String(verifiedCount),\n },\n {\n icon: Activity,\n label: tr('providers.summary.avgVendorHealth', 'Avg. vendor health'),\n value: avgHealth != null ? `${avgHealth}/100` : '—',\n },\n ];\n\n return (\n <PageLayout\n title={tr('providers.title', 'Discover providers')}\n description={tr(\n 'providers.description',\n 'Browse the global provider registry — the public face of the Subscription Knowledge Graph'\n )}\n actions={\n <button\n type=\"button\"\n onClick={() => navigateTo('/providers/new')}\n className=\"inline-flex items-center gap-2 rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text hover:opacity-90\"\n >\n <Plus className=\"h-4 w-4\" /> {tr('providers.addProvider', 'Add provider')}\n </button>\n }\n >\n <PagePurpose className=\"mb-4\">\n {tr(\n 'providers.purpose',\n 'Every provider exists here whether or not it integrates. Search and filter to find the ones you have relationships with, compare their vendor health, or add a new one.'\n )}\n </PagePurpose>\n\n <EmphasisPanel>\n <form onSubmit={submitSearch} className=\"relative\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <input\n type=\"text\"\n placeholder={tr('providers.searchPlaceholder', 'Search providers by name…')}\n value={searchInput}\n onChange={(e) => setSearchInput(e.target.value)}\n className=\"w-full rounded-lg border border-border-subtle bg-bg-surface py-2.5 pl-10 pr-24 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n />\n <button\n type=\"submit\"\n className=\"absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md bg-action-primary-bg px-3 py-1.5 text-xs font-medium text-action-primary-text hover:opacity-90\"\n >\n {tr('providers.search', 'Search')}\n </button>\n </form>\n </EmphasisPanel>\n\n {/* Category filter chips */}\n <div className=\"mt-4 flex items-center gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n {categoryOptions.map((o) => {\n const active = category === o.value;\n return (\n <button\n key={o.value || 'all'}\n type=\"button\"\n aria-pressed={active}\n onClick={() => {\n setCategory(o.value);\n setPage(0);\n }}\n className={cn(\n 'shrink-0 rounded-full border px-3.5 py-1.5 text-xs font-medium transition-colors',\n active\n ? 'border-transparent bg-action-primary-bg text-action-primary-text'\n : 'border-border-subtle bg-bg-surface text-text-secondary hover:bg-bg-sunken hover:text-text-primary'\n )}\n >\n {o.label}\n </button>\n );\n })}\n </div>\n\n <div className=\"mt-3 flex flex-wrap items-center gap-2\">\n <select\n aria-label={tr('providers.filters.statusLabel', 'Filter by status')}\n value={status}\n onChange={(e) => {\n setStatus(e.target.value as '' | SubProviderStatus);\n setPage(0);\n }}\n className=\"rounded-lg border border-border-subtle bg-bg-surface px-3 py-2 text-xs text-text-primary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n >\n {statusOptions.map((o) => (\n <option key={o.value} value={o.value}>\n {o.label}\n </option>\n ))}\n </select>\n </div>\n\n {/* Live summary strip */}\n {!isLoading && !isError && items.length > 0 && (\n <div className=\"mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3\">\n {summary.map((s) => (\n <GlassCard key={s.label} treatment=\"metric\" className=\"flex items-center gap-3 p-4\">\n <span className=\"flex size-9 shrink-0 items-center justify-center rounded-lg bg-[var(--color-accent-soft)] text-text-primary\">\n <s.icon className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0\">\n <p className=\"truncate text-xs text-text-secondary\">{s.label}</p>\n <p className=\"text-lg font-semibold tabular-nums text-text-primary\">{s.value}</p>\n </div>\n </GlassCard>\n ))}\n </div>\n )}\n\n {isLoading ? (\n <LoadingView label={tr('providers.loading', 'Loading providers…')} />\n ) : isError ? (\n <ErrorView\n message={tr('providers.error', 'Could not load the provider registry.')}\n onRetry={() => void refetch()}\n />\n ) : items.length === 0 ? (\n <div className=\"mt-6\">\n <EmptyState\n illustration=\"empty-search\"\n icon={<Building2 className=\"h-12 w-12\" />}\n title={tr('providers.emptyTitle', 'No providers found')}\n description={\n search\n ? tr('providers.emptyDescriptionSearch', 'No providers match \"{{search}}\".', {\n search,\n })\n : tr(\n 'providers.emptyDescription',\n 'No providers in the registry with these filters yet.'\n )\n }\n action={\n <button\n type=\"button\"\n onClick={() => navigateTo('/providers/new')}\n className=\"rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text hover:opacity-90\"\n >\n {tr('providers.addProvider', 'Add provider')}\n </button>\n }\n />\n </div>\n ) : (\n <>\n <div className=\"mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n {items.map((p) => (\n <ProviderCard\n key={p.id}\n provider={p}\n onOpen={() => navigateTo(`/providers/${p.id}`)}\n />\n ))}\n </div>\n\n <div className=\"mt-6 flex items-center justify-between\">\n <p className=\"text-xs text-text-secondary\">\n {tr('providers.pagination.page', 'Page {{page}}', { page: page + 1 })}\n {isFetching ? ` · ${tr('providers.pagination.refreshing', 'refreshing…')}` : ''}\n </p>\n <div className=\"flex gap-2\">\n <button\n type=\"button\"\n disabled={page === 0}\n onClick={() => setPage((p) => Math.max(0, p - 1))}\n className={cn(\n 'rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium',\n page === 0\n ? 'cursor-not-allowed text-text-secondary opacity-50'\n : 'text-text-primary hover:bg-bg-sunken'\n )}\n >\n {tr('providers.pagination.previous', 'Previous')}\n </button>\n <button\n type=\"button\"\n disabled={!hasMore}\n onClick={() => setPage((p) => p + 1)}\n className={cn(\n 'rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium',\n !hasMore\n ? 'cursor-not-allowed text-text-secondary opacity-50'\n : 'text-text-primary hover:bg-bg-sunken'\n )}\n >\n {tr('providers.pagination.next', 'Next')}\n </button>\n </div>\n </div>\n </>\n )}\n </PageLayout>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"ProvidersListPage.js","names":[],"sources":["../../../../src/subscriberbot/pages/providers/ProvidersListPage.tsx"],"sourcesContent":["/**\n * Providers list (Global Provider Registry)\n * @module subscriberbot/pages/providers\n *\n * Discovery marketplace: search and browse the provider registry. Server-side\n * search + category/status filters + pagination via `subListProviders`\n * (a SubProviderConnection). Category filter chips, a responsive grid of glass\n * provider cards with a real vendor-health indicator + churn hint, and a small\n * live summary strip computed from the fetched page.\n */\n\nimport { useState } from 'react';\nimport {\n Plus,\n Search,\n Building2,\n ShieldCheck,\n ChevronRight,\n Store,\n Activity,\n BadgeCheck,\n} from 'lucide-react';\nimport { EmphasisPanel, PagePurpose, GlassCard } from '@burdenoff/fe-libs/ui';\nimport { PageLayout } from '../../components/PageLayout';\nimport { StatusBadge } from '../../components/StatusBadge';\nimport { EmptyState } from '../../components/EmptyState';\nimport { LoadingView, ErrorView } from '../../components/StateViews';\nimport { MiniProgress } from '../../components';\nimport { useSubscriberBot } from '../../context/SubscriberBotContext';\nimport { useProviders } from '../../hooks/useSubscriberBotApi';\nimport { useTr } from '../../hooks/useTr';\nimport { cn } from '../../utils/cn';\nimport { formatEnumLabel } from '../../utils/formatters';\nimport type { SubProvider, SubProviderCategory, SubProviderStatus } from '../../types';\n\nconst PAGE_SIZE = 24;\n\nconst CATEGORY_KEY: Record<SubProviderCategory, string> = {\n SAAS: 'providers.categories.saas',\n AI_SERVICE: 'providers.categories.aiService',\n STREAMING: 'providers.categories.streaming',\n NEWSLETTER: 'providers.categories.newsletter',\n NEWS_DIGEST: 'providers.categories.newsDigest',\n MEMBERSHIP: 'providers.categories.membership',\n COMMUNITY: 'providers.categories.community',\n EDUCATION: 'providers.categories.education',\n FINANCIAL: 'providers.categories.financial',\n INSURANCE: 'providers.categories.insurance',\n TELECOM: 'providers.categories.telecom',\n UTILITY: 'providers.categories.utility',\n ASSOCIATION: 'providers.categories.association',\n LICENSE: 'providers.categories.license',\n CONTRACT: 'providers.categories.contract',\n OTHER: 'providers.categories.other',\n};\n\nconst STATUS_KEY: Record<SubProviderStatus, string> = {\n VERIFIED: 'providers.filters.verified',\n CLAIMED: 'providers.filters.claimed',\n UNVERIFIED: 'providers.filters.unverified',\n DEPRECATED: 'providers.filters.deprecated',\n};\n\n/** Map a 0–100 vendor-health score to a MiniProgress tone. Higher is healthier. */\nfunction healthTone(score: number): 'positive' | 'accent' | 'warning' | 'negative' {\n if (score >= 70) return 'positive';\n if (score >= 45) return 'accent';\n if (score >= 25) return 'warning';\n return 'negative';\n}\n\n/** Map a 0–100 churn-risk score to a label + semantic tone. Higher is worse. */\nfunction churnMeta(score: number, tr: ReturnType<typeof useTr>): { label: string; cls: string } {\n if (score >= 67)\n return {\n label: tr('providers.card.highChurn', 'High churn risk'),\n cls: 'bg-status-error-bg-subtle text-status-error-text',\n };\n if (score >= 34)\n return {\n label: tr('providers.card.moderateChurn', 'Moderate churn risk'),\n cls: 'bg-status-warning-bg-subtle text-status-warning-text',\n };\n return {\n label: tr('providers.card.lowChurn', 'Low churn risk'),\n cls: 'bg-status-success-bg-subtle text-status-success-text',\n };\n}\n\nfunction ProviderCard({ provider, onOpen }: { provider: SubProvider; onOpen: () => void }) {\n const tr = useTr();\n const health = provider.vendorHealthScore;\n const churn = provider.churnRiskScore;\n const churnInfo = churn != null ? churnMeta(churn, tr) : null;\n\n return (\n <GlassCard\n treatment=\"glass\"\n role=\"button\"\n tabIndex={0}\n onClick={onOpen}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n onOpen();\n }\n }}\n aria-label={tr('providers.card.openAria', 'Open {{name}}', { name: provider.name })}\n className=\"group flex cursor-pointer flex-col p-5 outline-none transition-all hover:-translate-y-0.5 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]\"\n >\n <div className=\"flex items-start justify-between gap-2\">\n <span className=\"flex size-10 items-center justify-center rounded-xl bg-[var(--color-accent-soft)] text-text-primary\">\n <Building2 className=\"h-5 w-5\" />\n </span>\n <StatusBadge status={provider.status} />\n </div>\n\n <p className=\"mt-3 truncate text-base font-semibold text-text-primary\">{provider.name}</p>\n <span className=\"mt-1 inline-flex w-fit rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\">\n {tr(CATEGORY_KEY[provider.category], formatEnumLabel(provider.category))}\n </span>\n\n {provider.description ? (\n <p className=\"mt-3 line-clamp-2 min-h-[2.5rem] text-xs leading-relaxed text-text-secondary\">\n {provider.description}\n </p>\n ) : (\n <p className=\"mt-3 min-h-[2.5rem] text-xs italic text-text-secondary/70\">\n {tr('providers.card.noDescription', 'No description yet.')}\n </p>\n )}\n\n <div className=\"mt-4 border-t border-border-subtle pt-3\">\n <div className=\"flex items-center justify-between text-xs\">\n <span className=\"inline-flex items-center gap-1 text-text-secondary\">\n <ShieldCheck className=\"h-3.5 w-3.5\" />{' '}\n {tr('providers.card.vendorHealth', 'Vendor health')}\n </span>\n <span className=\"font-semibold tabular-nums text-text-primary\">\n {health != null ? `${health}/100` : '—'}\n </span>\n </div>\n <MiniProgress\n value={health ?? 0}\n tone={health != null ? healthTone(health) : 'accent'}\n className=\"mt-1.5\"\n />\n </div>\n\n <div className=\"mt-3 flex flex-wrap items-center justify-between gap-2\">\n <div className=\"flex flex-wrap items-center gap-2\">\n {churnInfo && (\n <span\n className={cn(\n 'inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium',\n churnInfo.cls\n )}\n >\n {churnInfo.label}\n </span>\n )}\n {provider.region && (\n <span className=\"text-[11px] text-text-secondary\">{provider.region}</span>\n )}\n </div>\n <span className=\"inline-flex items-center gap-0.5 text-xs font-medium text-text-link opacity-0 transition-opacity group-hover:opacity-100\">\n {tr('providers.card.view', 'View')} <ChevronRight className=\"h-3.5 w-3.5\" />\n </span>\n </div>\n </GlassCard>\n );\n}\n\nexport function ProvidersListPage() {\n const tr = useTr();\n const { navigateTo } = useSubscriberBot();\n const [page, setPage] = useState(0);\n const [search, setSearch] = useState('');\n const [searchInput, setSearchInput] = useState('');\n const [category, setCategory] = useState<'' | SubProviderCategory>('');\n const [status, setStatus] = useState<'' | SubProviderStatus>('');\n\n const categoryOptions: Array<{ value: '' | SubProviderCategory; label: string }> = [\n { value: '', label: tr('providers.categories.all', 'All') },\n ...Object.entries(CATEGORY_KEY).map(([value, key]) => ({\n value: value as SubProviderCategory,\n label: tr(key, formatEnumLabel(value as SubProviderCategory)),\n })),\n ];\n\n const statusOptions: Array<{ value: '' | SubProviderStatus; label: string }> = [\n { value: '', label: tr('providers.filters.anyStatus', 'Any status') },\n ...Object.entries(STATUS_KEY).map(([value, key]) => ({\n value: value as SubProviderStatus,\n label: tr(key, formatEnumLabel(value as SubProviderStatus)),\n })),\n ];\n\n const { data, isLoading, isError, refetch, isFetching } = useProviders({\n search: search || undefined,\n category: category || undefined,\n status: status || undefined,\n pagination: { limit: PAGE_SIZE, offset: page * PAGE_SIZE },\n });\n\n const items = data?.items ?? [];\n const hasMore = data?.pageInfo.hasMore ?? false;\n\n // Live summary strip — computed from the fetched page (never fabricated).\n const verifiedCount = items.filter((p) => p.status === 'VERIFIED').length;\n const healthScores = items.map((p) => p.vendorHealthScore).filter((s): s is number => s != null);\n const avgHealth = healthScores.length\n ? Math.round(healthScores.reduce((a, b) => a + b, 0) / healthScores.length)\n : null;\n\n const submitSearch = (e: React.FormEvent) => {\n e.preventDefault();\n setSearch(searchInput.trim());\n setPage(0);\n };\n\n const summary: Array<{ icon: typeof Store; label: string; value: string }> = [\n {\n icon: Store,\n label: tr('providers.summary.onThisPage', 'On this page'),\n value: String(items.length),\n },\n {\n icon: BadgeCheck,\n label: tr('providers.filters.verified', 'Verified'),\n value: String(verifiedCount),\n },\n {\n icon: Activity,\n label: tr('providers.summary.avgVendorHealth', 'Avg. vendor health'),\n value: avgHealth != null ? `${avgHealth}/100` : '—',\n },\n ];\n\n return (\n <PageLayout\n title={tr('providers.title', 'Discover providers')}\n description={tr(\n 'providers.description',\n 'Browse the global provider registry — the public face of the Subscription Knowledge Graph'\n )}\n actions={\n <button\n type=\"button\"\n onClick={() => navigateTo('/providers/new')}\n className=\"inline-flex items-center gap-2 rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text hover:opacity-90\"\n >\n <Plus className=\"h-4 w-4\" /> {tr('providers.addProvider', 'Add provider')}\n </button>\n }\n >\n <PagePurpose className=\"mb-4\">\n {tr(\n 'providers.purpose',\n 'Every provider exists here whether or not it integrates. Search and filter to find the ones you have relationships with, compare their vendor health, or add a new one.'\n )}\n </PagePurpose>\n\n <EmphasisPanel>\n <form onSubmit={submitSearch} className=\"relative\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary\" />\n <input\n type=\"text\"\n aria-label={tr('providers.searchLabel', 'Search providers')}\n placeholder={tr('providers.searchPlaceholder', 'Search providers by name…')}\n value={searchInput}\n onChange={(e) => setSearchInput(e.target.value)}\n className=\"w-full rounded-lg border border-border-subtle bg-bg-surface py-2.5 pl-10 pr-24 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n />\n <button\n type=\"submit\"\n className=\"absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md bg-action-primary-bg px-3 py-1.5 text-xs font-medium text-action-primary-text hover:opacity-90\"\n >\n {tr('providers.search', 'Search')}\n </button>\n </form>\n </EmphasisPanel>\n\n {/* Category filter chips */}\n <div className=\"mt-4 flex items-center gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n {categoryOptions.map((o) => {\n const active = category === o.value;\n return (\n <button\n key={o.value || 'all'}\n type=\"button\"\n aria-pressed={active}\n onClick={() => {\n setCategory(o.value);\n setPage(0);\n }}\n className={cn(\n 'shrink-0 rounded-full border px-3.5 py-1.5 text-xs font-medium transition-colors',\n active\n ? 'border-transparent bg-action-primary-bg text-action-primary-text'\n : 'border-border-subtle bg-bg-surface text-text-secondary hover:bg-bg-sunken hover:text-text-primary'\n )}\n >\n {o.label}\n </button>\n );\n })}\n </div>\n\n <div className=\"mt-3 flex flex-wrap items-center gap-2\">\n <select\n aria-label={tr('providers.filters.statusLabel', 'Filter by status')}\n value={status}\n onChange={(e) => {\n setStatus(e.target.value as '' | SubProviderStatus);\n setPage(0);\n }}\n className=\"rounded-lg border border-border-subtle bg-bg-surface px-3 py-2 text-xs text-text-primary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]\"\n >\n {statusOptions.map((o) => (\n <option key={o.value} value={o.value}>\n {o.label}\n </option>\n ))}\n </select>\n </div>\n\n {/* Live summary strip */}\n {!isLoading && !isError && items.length > 0 && (\n <div className=\"mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3\">\n {summary.map((s) => (\n <GlassCard key={s.label} treatment=\"metric\" className=\"flex items-center gap-3 p-4\">\n <span className=\"flex size-9 shrink-0 items-center justify-center rounded-lg bg-[var(--color-accent-soft)] text-text-primary\">\n <s.icon className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0\">\n <p className=\"truncate text-xs text-text-secondary\">{s.label}</p>\n <p className=\"text-lg font-semibold tabular-nums text-text-primary\">{s.value}</p>\n </div>\n </GlassCard>\n ))}\n </div>\n )}\n\n {isLoading ? (\n <LoadingView label={tr('providers.loading', 'Loading providers…')} />\n ) : isError ? (\n <ErrorView\n message={tr('providers.error', 'Could not load the provider registry.')}\n onRetry={() => void refetch()}\n />\n ) : items.length === 0 ? (\n <div className=\"mt-6\">\n <EmptyState\n illustration=\"empty-search\"\n icon={<Building2 className=\"h-12 w-12\" />}\n title={tr('providers.emptyTitle', 'No providers found')}\n description={\n search\n ? tr('providers.emptyDescriptionSearch', 'No providers match \"{{search}}\".', {\n search,\n })\n : tr(\n 'providers.emptyDescription',\n 'No providers in the registry with these filters yet.'\n )\n }\n action={\n <button\n type=\"button\"\n onClick={() => navigateTo('/providers/new')}\n className=\"rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text hover:opacity-90\"\n >\n {tr('providers.addProvider', 'Add provider')}\n </button>\n }\n />\n </div>\n ) : (\n <>\n <div className=\"mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n {items.map((p) => (\n <ProviderCard\n key={p.id}\n provider={p}\n onOpen={() => navigateTo(`/providers/${p.id}`)}\n />\n ))}\n </div>\n\n <div className=\"mt-6 flex items-center justify-between\">\n <p className=\"text-xs text-text-secondary\">\n {tr('providers.pagination.page', 'Page {{page}}', { page: page + 1 })}\n {isFetching ? ` · ${tr('providers.pagination.refreshing', 'refreshing…')}` : ''}\n </p>\n <div className=\"flex gap-2\">\n <button\n type=\"button\"\n disabled={page === 0}\n onClick={() => setPage((p) => Math.max(0, p - 1))}\n className={cn(\n 'rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium',\n page === 0\n ? 'cursor-not-allowed text-text-secondary opacity-50'\n : 'text-text-primary hover:bg-bg-sunken'\n )}\n >\n {tr('providers.pagination.previous', 'Previous')}\n </button>\n <button\n type=\"button\"\n disabled={!hasMore}\n onClick={() => setPage((p) => p + 1)}\n className={cn(\n 'rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium',\n !hasMore\n ? 'cursor-not-allowed text-text-secondary opacity-50'\n : 'text-text-primary hover:bg-bg-sunken'\n )}\n >\n {tr('providers.pagination.next', 'Next')}\n </button>\n </div>\n </div>\n </>\n )}\n </PageLayout>\n );\n}\n"],"mappings":"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"}
@@ -17,6 +17,7 @@
17
17
  * @example
18
18
  * formatCurrency(12, 'USD') // => "$12.00"
19
19
  * formatCurrency(999, 'INR', { minimumFractionDigits: 0 }) // => "₹999"
20
+ * formatCurrency(55, 'USD', { maximumFractionDigits: 0 }) // => "$55"
20
21
  */
21
22
  export declare function formatCurrency(amount: number, currency?: string, options?: Partial<Intl.NumberFormatOptions>): string;
22
23
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"formatters.d.ts","sourceRoot":"","sources":["../../../src/subscriberbot/utils/formatters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,MAAM,EACd,QAAQ,SAAQ,EAChB,OAAO,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,mBAAmB,CAAC,GAC1C,MAAM,CAaR;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,GAAG,SAAS,EACtC,OAAO,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,qBAAqB,CAAC,GAC5C,MAAM,CAUR;AAED;;GAEG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,GAAG,SAAS,EACtC,OAAO,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,qBAAqB,CAAC,GAC5C,MAAM,CAYR;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CA4BjF;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,mBAAmB,CAAC,GAAG,MAAM,CAE/F;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKrD;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAqB7D"}
1
+ {"version":3,"file":"formatters.d.ts","sourceRoot":"","sources":["../../../src/subscriberbot/utils/formatters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,MAAM,EACd,QAAQ,SAAQ,EAChB,OAAO,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,mBAAmB,CAAC,GAC1C,MAAM,CAoBR;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,GAAG,SAAS,EACtC,OAAO,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,qBAAqB,CAAC,GAC5C,MAAM,CAUR;AAED;;GAEG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,GAAG,SAAS,EACtC,OAAO,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,qBAAqB,CAAC,GAC5C,MAAM,CAYR;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CA4BjF;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,mBAAmB,CAAC,GAAG,MAAM,CAE/F;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKrD;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAqB7D"}
@@ -1,12 +1,13 @@
1
1
  //#region src/subscriberbot/utils/formatters.ts
2
2
  function e(e, t = "USD", n) {
3
+ let r = n?.maximumFractionDigits ?? 2, i = n?.minimumFractionDigits ?? Math.min(2, r);
3
4
  try {
4
5
  return new Intl.NumberFormat(void 0, {
5
6
  style: "currency",
6
7
  currency: t || "USD",
7
- minimumFractionDigits: 2,
8
- maximumFractionDigits: 2,
9
- ...n
8
+ ...n,
9
+ minimumFractionDigits: i,
10
+ maximumFractionDigits: r
10
11
  }).format(e);
11
12
  } catch {
12
13
  return `${e.toFixed(2)} ${t}`;
@@ -1 +1 @@
1
- {"version":3,"file":"formatters.js","names":[],"sources":["../../../src/subscriberbot/utils/formatters.ts"],"sourcesContent":["/**\n * Formatting utilities for the Subscriber Bot microfrontend\n * @module subscriberbot/utils/formatters\n *\n * Subscriber Bot is a global product — relationships are billed in many\n * currencies. Formatting is therefore currency-aware (each Relationship /\n * Invoice / Plan carries its own ISO currency code).\n */\n\n/**\n * Format an amount as currency using the relationship/invoice's own ISO code.\n *\n * @param amount - The numeric amount to format\n * @param currency - ISO 4217 currency code (e.g. \"USD\", \"EUR\", \"INR\")\n * @param options - Optional Intl.NumberFormat overrides\n * @returns Formatted currency string (e.g. \"$12.00\")\n *\n * @example\n * formatCurrency(12, 'USD') // => \"$12.00\"\n * formatCurrency(999, 'INR', { minimumFractionDigits: 0 }) // => \"₹999\"\n */\nexport function formatCurrency(\n amount: number,\n currency = 'USD',\n options?: Partial<Intl.NumberFormatOptions>\n): string {\n try {\n return new Intl.NumberFormat(undefined, {\n style: 'currency',\n currency: currency || 'USD',\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n ...options,\n }).format(amount);\n } catch {\n // Unknown/invalid currency code → fall back to a plain number + code.\n return `${amount.toFixed(2)} ${currency}`;\n }\n}\n\n/**\n * Format a date string or Date object into a human-readable date.\n *\n * @param date - ISO date string or Date object\n * @param options - Optional Intl.DateTimeFormat options\n * @returns Formatted date string (e.g. \"15 Jan 2026\")\n */\nexport function formatDate(\n date: string | Date | null | undefined,\n options?: Partial<Intl.DateTimeFormatOptions>\n): string {\n if (!date) return '—';\n const d = typeof date === 'string' ? new Date(date) : date;\n if (Number.isNaN(d.getTime())) return '—';\n return new Intl.DateTimeFormat(undefined, {\n day: 'numeric',\n month: 'short',\n year: 'numeric',\n ...options,\n }).format(d);\n}\n\n/**\n * Format a date-time into a date + time string.\n */\nexport function formatDateTime(\n date: string | Date | null | undefined,\n options?: Partial<Intl.DateTimeFormatOptions>\n): string {\n if (!date) return '—';\n const d = typeof date === 'string' ? new Date(date) : date;\n if (Number.isNaN(d.getTime())) return '—';\n return new Intl.DateTimeFormat(undefined, {\n day: 'numeric',\n month: 'short',\n year: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n ...options,\n }).format(d);\n}\n\n/**\n * Format a date into a relative time string (e.g. \"2 hours ago\", \"in 3 days\").\n *\n * @param date - ISO date string or Date object\n * @returns Human-readable relative time string\n */\nexport function formatRelativeTime(date: string | Date | null | undefined): string {\n if (!date) return '—';\n const d = typeof date === 'string' ? new Date(date) : date;\n if (Number.isNaN(d.getTime())) return '—';\n\n const now = new Date();\n const diffMs = d.getTime() - now.getTime();\n const future = diffMs >= 0;\n const absSeconds = Math.floor(Math.abs(diffMs) / 1000);\n const minutes = Math.floor(absSeconds / 60);\n const hours = Math.floor(minutes / 60);\n const days = Math.floor(hours / 24);\n const weeks = Math.floor(days / 7);\n const months = Math.floor(days / 30);\n const years = Math.floor(days / 365);\n\n const phrase = (value: number, unit: string): string => {\n const label = `${value} ${unit}${value !== 1 ? 's' : ''}`;\n return future ? `in ${label}` : `${label} ago`;\n };\n\n if (absSeconds < 60) return future ? 'soon' : 'just now';\n if (minutes < 60) return phrase(minutes, 'min');\n if (hours < 24) return phrase(hours, 'hour');\n if (days < 7) return phrase(days, 'day');\n if (weeks < 4) return phrase(weeks, 'week');\n if (months < 12) return phrase(months, 'month');\n return phrase(years, 'year');\n}\n\n/**\n * Format a number with locale grouping.\n */\nexport function formatNumber(value: number, options?: Partial<Intl.NumberFormatOptions>): string {\n return new Intl.NumberFormat(undefined, options).format(value);\n}\n\n/**\n * Turn an ENUM_LIKE_TOKEN into a human \"Enum Like Token\" label.\n */\nexport function formatEnumLabel(value: string): string {\n return value\n .toLowerCase()\n .replace(/_/g, ' ')\n .replace(/\\b\\w/g, (char) => char.toUpperCase());\n}\n\n/**\n * Format a billing interval enum into a short suffix (e.g. \"MONTHLY\" → \"/mo\").\n */\nexport function formatIntervalSuffix(interval: string): string {\n switch (interval) {\n case 'DAILY':\n return '/day';\n case 'WEEKLY':\n return '/wk';\n case 'MONTHLY':\n return '/mo';\n case 'QUARTERLY':\n return '/qtr';\n case 'YEARLY':\n return '/yr';\n case 'LIFETIME':\n return ' lifetime';\n case 'USAGE_BASED':\n return ' usage';\n case 'FREE':\n return '';\n default:\n return '';\n }\n}\n"],"mappings":";AAqBA,SAAgB,EACd,GACA,IAAW,OACX,GACQ;CACR,IAAI;EACF,OAAO,IAAI,KAAK,aAAa,KAAA,GAAW;GACtC,OAAO;GACP,UAAU,KAAY;GACtB,uBAAuB;GACvB,uBAAuB;GACvB,GAAG;EACL,CAAC,EAAE,OAAO,CAAM;CAClB,QAAQ;EAEN,OAAO,GAAG,EAAO,QAAQ,CAAC,EAAE,GAAG;CACjC;AACF;AASA,SAAgB,EACd,GACA,GACQ;CACR,IAAI,CAAC,GAAM,OAAO;CAClB,IAAM,IAAI,OAAO,KAAS,WAAW,IAAI,KAAK,CAAI,IAAI;CAEtD,OADI,OAAO,MAAM,EAAE,QAAQ,CAAC,IAAU,MAC/B,IAAI,KAAK,eAAe,KAAA,GAAW;EACxC,KAAK;EACL,OAAO;EACP,MAAM;EACN,GAAG;CACL,CAAC,EAAE,OAAO,CAAC;AACb;AAKA,SAAgB,EACd,GACA,GACQ;CACR,IAAI,CAAC,GAAM,OAAO;CAClB,IAAM,IAAI,OAAO,KAAS,WAAW,IAAI,KAAK,CAAI,IAAI;CAEtD,OADI,OAAO,MAAM,EAAE,QAAQ,CAAC,IAAU,MAC/B,IAAI,KAAK,eAAe,KAAA,GAAW;EACxC,KAAK;EACL,OAAO;EACP,MAAM;EACN,MAAM;EACN,QAAQ;EACR,GAAG;CACL,CAAC,EAAE,OAAO,CAAC;AACb;AAQA,SAAgB,EAAmB,GAAgD;CACjF,IAAI,CAAC,GAAM,OAAO;CAClB,IAAM,IAAI,OAAO,KAAS,WAAW,IAAI,KAAK,CAAI,IAAI;CACtD,IAAI,OAAO,MAAM,EAAE,QAAQ,CAAC,GAAG,OAAO;CAEtC,IAAM,oBAAM,IAAI,KAAK,GACf,IAAS,EAAE,QAAQ,IAAI,EAAI,QAAQ,GACnC,IAAS,KAAU,GACnB,IAAa,KAAK,MAAM,KAAK,IAAI,CAAM,IAAI,GAAI,GAC/C,IAAU,KAAK,MAAM,IAAa,EAAE,GACpC,IAAQ,KAAK,MAAM,IAAU,EAAE,GAC/B,IAAO,KAAK,MAAM,IAAQ,EAAE,GAC5B,IAAQ,KAAK,MAAM,IAAO,CAAC,GAC3B,IAAS,KAAK,MAAM,IAAO,EAAE,GAC7B,IAAQ,KAAK,MAAM,IAAO,GAAG,GAE7B,KAAU,GAAe,MAAyB;EACtD,IAAM,IAAQ,GAAG,EAAM,GAAG,IAAO,MAAU,IAAU,KAAN;EAC/C,OAAO,IAAS,MAAM,MAAU,GAAG,EAAM;CAC3C;CAQA,OANI,IAAa,KAAW,IAAS,SAAS,aAC1C,IAAU,KAAW,EAAO,GAAS,KAAK,IAC1C,IAAQ,KAAW,EAAO,GAAO,MAAM,IACvC,IAAO,IAAU,EAAO,GAAM,KAAK,IACnC,IAAQ,IAAU,EAAO,GAAO,MAAM,IACtC,IAAS,KAAW,EAAO,GAAQ,OAAO,IACvC,EAAO,GAAO,MAAM;AAC7B;AAKA,SAAgB,EAAa,GAAe,GAAqD;CAC/F,OAAO,IAAI,KAAK,aAAa,KAAA,GAAW,CAAO,EAAE,OAAO,CAAK;AAC/D;AAKA,SAAgB,EAAgB,GAAuB;CACrD,OAAO,EACJ,YAAY,EACZ,QAAQ,MAAM,GAAG,EACjB,QAAQ,UAAU,MAAS,EAAK,YAAY,CAAC;AAClD;AAKA,SAAgB,EAAqB,GAA0B;CAC7D,QAAQ,GAAR;EACE,KAAK,SACH,OAAO;EACT,KAAK,UACH,OAAO;EACT,KAAK,WACH,OAAO;EACT,KAAK,aACH,OAAO;EACT,KAAK,UACH,OAAO;EACT,KAAK,YACH,OAAO;EACT,KAAK,eACH,OAAO;EACT,KAAK,QACH,OAAO;EACT,SACE,OAAO;CACX;AACF"}
1
+ {"version":3,"file":"formatters.js","names":[],"sources":["../../../src/subscriberbot/utils/formatters.ts"],"sourcesContent":["/**\n * Formatting utilities for the Subscriber Bot microfrontend\n * @module subscriberbot/utils/formatters\n *\n * Subscriber Bot is a global product — relationships are billed in many\n * currencies. Formatting is therefore currency-aware (each Relationship /\n * Invoice / Plan carries its own ISO currency code).\n */\n\n/**\n * Format an amount as currency using the relationship/invoice's own ISO code.\n *\n * @param amount - The numeric amount to format\n * @param currency - ISO 4217 currency code (e.g. \"USD\", \"EUR\", \"INR\")\n * @param options - Optional Intl.NumberFormat overrides\n * @returns Formatted currency string (e.g. \"$12.00\")\n *\n * @example\n * formatCurrency(12, 'USD') // => \"$12.00\"\n * formatCurrency(999, 'INR', { minimumFractionDigits: 0 }) // => \"₹999\"\n * formatCurrency(55, 'USD', { maximumFractionDigits: 0 }) // => \"$55\"\n */\nexport function formatCurrency(\n amount: number,\n currency = 'USD',\n options?: Partial<Intl.NumberFormatOptions>\n): string {\n // Intl throws a RangeError when minimumFractionDigits > maximumFractionDigits,\n // so a caller that only lowers the maximum (e.g. whole-unit KPI tiles) must\n // also lower the default minimum instead of falling through to the\n // plain-number fallback below.\n const maximumFractionDigits = options?.maximumFractionDigits ?? 2;\n const minimumFractionDigits =\n options?.minimumFractionDigits ?? Math.min(2, maximumFractionDigits);\n try {\n return new Intl.NumberFormat(undefined, {\n style: 'currency',\n currency: currency || 'USD',\n ...options,\n minimumFractionDigits,\n maximumFractionDigits,\n }).format(amount);\n } catch {\n // Unknown/invalid currency code → fall back to a plain number + code.\n return `${amount.toFixed(2)} ${currency}`;\n }\n}\n\n/**\n * Format a date string or Date object into a human-readable date.\n *\n * @param date - ISO date string or Date object\n * @param options - Optional Intl.DateTimeFormat options\n * @returns Formatted date string (e.g. \"15 Jan 2026\")\n */\nexport function formatDate(\n date: string | Date | null | undefined,\n options?: Partial<Intl.DateTimeFormatOptions>\n): string {\n if (!date) return '—';\n const d = typeof date === 'string' ? new Date(date) : date;\n if (Number.isNaN(d.getTime())) return '—';\n return new Intl.DateTimeFormat(undefined, {\n day: 'numeric',\n month: 'short',\n year: 'numeric',\n ...options,\n }).format(d);\n}\n\n/**\n * Format a date-time into a date + time string.\n */\nexport function formatDateTime(\n date: string | Date | null | undefined,\n options?: Partial<Intl.DateTimeFormatOptions>\n): string {\n if (!date) return '—';\n const d = typeof date === 'string' ? new Date(date) : date;\n if (Number.isNaN(d.getTime())) return '—';\n return new Intl.DateTimeFormat(undefined, {\n day: 'numeric',\n month: 'short',\n year: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n ...options,\n }).format(d);\n}\n\n/**\n * Format a date into a relative time string (e.g. \"2 hours ago\", \"in 3 days\").\n *\n * @param date - ISO date string or Date object\n * @returns Human-readable relative time string\n */\nexport function formatRelativeTime(date: string | Date | null | undefined): string {\n if (!date) return '—';\n const d = typeof date === 'string' ? new Date(date) : date;\n if (Number.isNaN(d.getTime())) return '—';\n\n const now = new Date();\n const diffMs = d.getTime() - now.getTime();\n const future = diffMs >= 0;\n const absSeconds = Math.floor(Math.abs(diffMs) / 1000);\n const minutes = Math.floor(absSeconds / 60);\n const hours = Math.floor(minutes / 60);\n const days = Math.floor(hours / 24);\n const weeks = Math.floor(days / 7);\n const months = Math.floor(days / 30);\n const years = Math.floor(days / 365);\n\n const phrase = (value: number, unit: string): string => {\n const label = `${value} ${unit}${value !== 1 ? 's' : ''}`;\n return future ? `in ${label}` : `${label} ago`;\n };\n\n if (absSeconds < 60) return future ? 'soon' : 'just now';\n if (minutes < 60) return phrase(minutes, 'min');\n if (hours < 24) return phrase(hours, 'hour');\n if (days < 7) return phrase(days, 'day');\n if (weeks < 4) return phrase(weeks, 'week');\n if (months < 12) return phrase(months, 'month');\n return phrase(years, 'year');\n}\n\n/**\n * Format a number with locale grouping.\n */\nexport function formatNumber(value: number, options?: Partial<Intl.NumberFormatOptions>): string {\n return new Intl.NumberFormat(undefined, options).format(value);\n}\n\n/**\n * Turn an ENUM_LIKE_TOKEN into a human \"Enum Like Token\" label.\n */\nexport function formatEnumLabel(value: string): string {\n return value\n .toLowerCase()\n .replace(/_/g, ' ')\n .replace(/\\b\\w/g, (char) => char.toUpperCase());\n}\n\n/**\n * Format a billing interval enum into a short suffix (e.g. \"MONTHLY\" → \"/mo\").\n */\nexport function formatIntervalSuffix(interval: string): string {\n switch (interval) {\n case 'DAILY':\n return '/day';\n case 'WEEKLY':\n return '/wk';\n case 'MONTHLY':\n return '/mo';\n case 'QUARTERLY':\n return '/qtr';\n case 'YEARLY':\n return '/yr';\n case 'LIFETIME':\n return ' lifetime';\n case 'USAGE_BASED':\n return ' usage';\n case 'FREE':\n return '';\n default:\n return '';\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burdenoff/microfe-subscriberbot",
3
- "version": "2026.1030.7",
3
+ "version": "2026.1031.1",
4
4
  "description": "Burdenoff SubscriberBot Microfrontend",
5
5
  "type": "module",
6
6
  "files": [
@@ -39,6 +39,7 @@
39
39
  "storybook": "storybook dev -p 6006",
40
40
  "build-storybook": "storybook build",
41
41
  "test:storybook": "test-storybook",
42
+ "test:storybook:static": "bash scripts/storybook-test.sh",
42
43
  "switch:status": "bun scripts/deps-mode.ts status",
43
44
  "switch:remote": "bun scripts/deps-mode.ts remote",
44
45
  "switch:local": "bun scripts/deps-mode.ts local",