@tumbaland/frontend-core 1.14.1 → 1.16.0

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 (81) hide show
  1. package/dist/authService.d.ts +10 -0
  2. package/dist/authService.js +18 -3
  3. package/dist/components/AppErrorBoundary/AppErrorBoundary.d.ts +26 -0
  4. package/dist/components/AppErrorBoundary/AppErrorBoundary.js +25 -0
  5. package/dist/components/AppErrorBoundary/index.d.ts +2 -0
  6. package/dist/components/AppErrorBoundary/index.js +1 -0
  7. package/dist/components/AppLayout/AppLayout.d.ts +27 -0
  8. package/dist/components/AppLayout/AppLayout.js +45 -0
  9. package/dist/components/AppLayout/index.d.ts +2 -0
  10. package/dist/components/AppLayout/index.js +1 -0
  11. package/dist/components/AuthHeader/AuthHeader.d.ts +57 -0
  12. package/dist/components/AuthHeader/AuthHeader.js +135 -0
  13. package/dist/components/AuthHeader/index.d.ts +2 -0
  14. package/dist/components/AuthHeader/index.js +1 -0
  15. package/dist/components/ErrorPage/ErrorPage.d.ts +17 -0
  16. package/dist/components/ErrorPage/ErrorPage.js +19 -0
  17. package/dist/components/ErrorPage/index.d.ts +2 -0
  18. package/dist/components/ErrorPage/index.js +1 -0
  19. package/dist/components/Loader/Loader.d.ts +12 -0
  20. package/dist/components/Loader/Loader.js +15 -0
  21. package/dist/components/Loader/index.d.ts +2 -0
  22. package/dist/components/Loader/index.js +1 -0
  23. package/dist/components/LoginButton/LoginButton.d.ts +5 -0
  24. package/dist/components/LoginButton/LoginButton.js +15 -0
  25. package/dist/components/LoginButton/index.d.ts +2 -0
  26. package/dist/components/LoginButton/index.js +1 -0
  27. package/dist/components/NotificationsMenu/NotificationsMenu.d.ts +9 -0
  28. package/dist/components/NotificationsMenu/NotificationsMenu.js +29 -0
  29. package/dist/components/ProtectedRoute/ProtectedRoute.d.ts +33 -0
  30. package/dist/components/ProtectedRoute/ProtectedRoute.js +50 -0
  31. package/dist/components/ProtectedRoute/index.d.ts +2 -0
  32. package/dist/components/ProtectedRoute/index.js +1 -0
  33. package/dist/components/SectionBar/SectionBar.d.ts +49 -0
  34. package/dist/components/SectionBar/SectionBar.js +143 -0
  35. package/dist/components/SectionBar/index.d.ts +2 -0
  36. package/dist/components/SectionBar/index.js +1 -0
  37. package/dist/components/UnauthorizedPage/UnauthorizedPage.d.ts +5 -0
  38. package/dist/components/UnauthorizedPage/UnauthorizedPage.js +9 -0
  39. package/dist/components/UnauthorizedPage/index.d.ts +2 -0
  40. package/dist/components/UnauthorizedPage/index.js +1 -0
  41. package/dist/components/UsageMeter/UsageMeter.d.ts +35 -0
  42. package/dist/components/UsageMeter/UsageMeter.js +50 -0
  43. package/dist/components/UsageMeter/index.d.ts +4 -0
  44. package/dist/components/UsageMeter/index.js +2 -0
  45. package/dist/components/UsageMeter/useEntitlements.d.ts +27 -0
  46. package/dist/components/UsageMeter/useEntitlements.js +56 -0
  47. package/dist/components/tenant/TenantProvider.d.ts +11 -0
  48. package/dist/components/tenant/TenantProvider.js +53 -0
  49. package/dist/components/tenant/TenantSelector.d.ts +33 -0
  50. package/dist/components/tenant/TenantSelector.js +88 -0
  51. package/dist/components/tenant/index.d.ts +5 -0
  52. package/dist/components/tenant/index.js +3 -0
  53. package/dist/components/tenant/useTenant.d.ts +15 -0
  54. package/dist/components/tenant/useTenant.js +278 -0
  55. package/dist/components/tenant/useTenantDataRefresh.d.ts +6 -0
  56. package/dist/components/tenant/useTenantDataRefresh.js +28 -0
  57. package/dist/config/createConfigProvider.d.ts +44 -0
  58. package/dist/config/createConfigProvider.js +92 -0
  59. package/dist/federation.d.ts +46 -0
  60. package/dist/federation.js +37 -0
  61. package/dist/index.d.ts +28 -0
  62. package/dist/index.js +35 -1
  63. package/dist/monitoring/batch.js +3 -3
  64. package/dist/monitoring/config.js +1 -4
  65. package/dist/monitoring/console.js +1 -1
  66. package/dist/plansService.js +1 -1
  67. package/dist/routing/embedContext.d.ts +31 -0
  68. package/dist/routing/embedContext.js +63 -0
  69. package/dist/routing/index.d.ts +12 -0
  70. package/dist/routing/index.js +12 -0
  71. package/dist/sessionStorage.d.ts +1 -1
  72. package/dist/sessionStorage.js +1 -1
  73. package/dist/theme/createTumbalandTheme.d.ts +38 -0
  74. package/dist/theme/createTumbalandTheme.js +245 -0
  75. package/dist/theme/index.d.ts +2 -0
  76. package/dist/theme/index.js +2 -0
  77. package/dist/theme/tokens.d.ts +186 -0
  78. package/dist/theme/tokens.js +182 -0
  79. package/package.json +28 -2
  80. package/dist/test/setup.d.ts +0 -9
  81. package/dist/test/setup.js +0 -26
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { logger } from '../../monitoring';
4
+ import { Loader } from '../Loader';
5
+ import { UnauthorizedPage } from '../UnauthorizedPage';
6
+ /**
7
+ * Gates its children behind an authenticated session: renders a Loader while
8
+ * the check is in flight, UnauthorizedPage (whose login action calls the auth
9
+ * service's redirectToLogin) when unauthenticated or the check fails, and the
10
+ * children once authenticated.
11
+ *
12
+ * The first state comes from the auth service's cache when it has one, so a
13
+ * remount does not flash "Checking authentication..." over an answer already
14
+ * held in memory. The shell mounts and unmounts this every time the user
15
+ * crosses between its own pages and a framed module — which, on a phone, is
16
+ * every other tap on the tab bar — and each of those was a spinner for an
17
+ * answer that never changed. The check below still runs either way; the cache
18
+ * only decides what is painted while it does.
19
+ *
20
+ * Consolidated from seven per-front copies that had drifted into two
21
+ * behaviourally different variants (one polling `checkAuth`, one
22
+ * `isAuthenticated`, with different initial/loading semantics).
23
+ */
24
+ export function ProtectedRoute({ children, authService, loadingMessage = 'Checking authentication...' }) {
25
+ const [isAuthenticated, setIsAuthenticated] = useState(() => authService.getCachedAuth?.()?.authenticated ?? null);
26
+ useEffect(() => {
27
+ let active = true;
28
+ authService
29
+ .isAuthenticated()
30
+ .then((authenticated) => {
31
+ if (active)
32
+ setIsAuthenticated(authenticated);
33
+ })
34
+ .catch((error) => {
35
+ logger.error('Auth check failed', error);
36
+ if (active)
37
+ setIsAuthenticated(false);
38
+ });
39
+ return () => {
40
+ active = false;
41
+ };
42
+ }, [authService]);
43
+ if (isAuthenticated === null) {
44
+ return _jsx(Loader, { message: loadingMessage });
45
+ }
46
+ if (!isAuthenticated) {
47
+ return _jsx(UnauthorizedPage, { onLogin: () => authService.redirectToLogin() });
48
+ }
49
+ return _jsx(_Fragment, { children: children });
50
+ }
@@ -0,0 +1,2 @@
1
+ export { ProtectedRoute } from './ProtectedRoute';
2
+ export type { ProtectedRouteProps, ProtectedRouteAuthService } from './ProtectedRoute';
@@ -0,0 +1 @@
1
+ export { ProtectedRoute } from './ProtectedRoute';
@@ -0,0 +1,49 @@
1
+ import { ComponentType, ReactNode } from 'react';
2
+ import type { SvgIconProps } from '@mui/material/SvgIcon';
3
+ /** One section of a module: a page of it the navigation lists by name. */
4
+ export interface SectionBarItem {
5
+ label: string;
6
+ /** Absolute address, the same one in the shell and standalone (see each app's appBase). */
7
+ route: string;
8
+ icon?: ComponentType<SvgIconProps>;
9
+ }
10
+ export interface SectionBarProps {
11
+ /** The module the sections belong to. Named at the left of the bar. */
12
+ title: string;
13
+ /** Drawn in a tinted tile before the title. */
14
+ icon?: ComponentType<SvgIconProps>;
15
+ /** The module's accent. Tints the tile and the section being shown. */
16
+ accent?: string;
17
+ /** In menu order. A module with one section passes none and gets just a title. */
18
+ items?: SectionBarItem[];
19
+ currentRoute: string;
20
+ /** Phones get one dropdown in place of the row, and no title. */
21
+ compact?: boolean;
22
+ /** Pinned to the right of the bar — where the shell puts the tenant selector. */
23
+ end?: ReactNode;
24
+ /**
25
+ * Drawn at the left in place of the title, for a page that has something
26
+ * better to put there than its own name — the shell's home page puts the
27
+ * wordmark here. Shown on the same terms as the title, so a module with
28
+ * sections still spends the room on them.
29
+ */
30
+ lead?: ReactNode;
31
+ }
32
+ /**
33
+ * The one bar above a module's content: what you are in, and where else you can
34
+ * go inside it.
35
+ *
36
+ * It lives here rather than in the shell because a module renders it in both
37
+ * the modes it runs in. Inside the shell one bar is drawn for whichever module
38
+ * is loaded; standalone, the module's own `AppLayout` draws the same bar from
39
+ * the same section list. The alternative — in-page MUI `Tabs` — is what had
40
+ * Profile and Billing looking nothing like Journal, Album and Money, and put a
41
+ * second row of navigation under the shell's own.
42
+ *
43
+ * Sections are laid out in full on a wide screen, since a row of five names is
44
+ * worth more than a button that hides four of them. Below `sm` there is no room
45
+ * for that, so the same list becomes a dropdown labelled with the section being
46
+ * shown.
47
+ */
48
+ export declare function SectionBar({ title, icon: Icon, accent, items, currentRoute, compact, end, lead }: SectionBarProps): import("react").JSX.Element;
49
+ export default SectionBar;
@@ -0,0 +1,143 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Link } from 'react-router';
4
+ import Box from '@mui/material/Box';
5
+ import Button from '@mui/material/Button';
6
+ import ListItemIcon from '@mui/material/ListItemIcon';
7
+ import ListItemText from '@mui/material/ListItemText';
8
+ import Menu from '@mui/material/Menu';
9
+ import MenuItem from '@mui/material/MenuItem';
10
+ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
11
+ import { brand, chrome, ink, surface, TOUCH_TARGET } from '../../theme/tokens';
12
+ /** A section owns its own path and everything nested under it. */
13
+ const isSectionActive = (route, currentRoute) => currentRoute === route || currentRoute.startsWith(`${route}/`);
14
+ /**
15
+ * The one bar above a module's content: what you are in, and where else you can
16
+ * go inside it.
17
+ *
18
+ * It lives here rather than in the shell because a module renders it in both
19
+ * the modes it runs in. Inside the shell one bar is drawn for whichever module
20
+ * is loaded; standalone, the module's own `AppLayout` draws the same bar from
21
+ * the same section list. The alternative — in-page MUI `Tabs` — is what had
22
+ * Profile and Billing looking nothing like Journal, Album and Money, and put a
23
+ * second row of navigation under the shell's own.
24
+ *
25
+ * Sections are laid out in full on a wide screen, since a row of five names is
26
+ * worth more than a button that hides four of them. Below `sm` there is no room
27
+ * for that, so the same list becomes a dropdown labelled with the section being
28
+ * shown.
29
+ */
30
+ export function SectionBar({ title, icon: Icon, accent = brand.pinkInk, items = [], currentRoute, compact = false, end, lead }) {
31
+ const [anchorEl, setAnchorEl] = useState(null);
32
+ const open = Boolean(anchorEl);
33
+ const close = () => setAnchorEl(null);
34
+ const activeItem = items.find(({ route }) => isSectionActive(route, currentRoute));
35
+ const sections = items.length > 0 && (_jsx(Box, { component: "nav", "aria-label": `${title} sections`, sx: compact ? compactNav : sectionRow, children: compact ? (_jsx(Button
36
+ // No aria-label: the accessible name is the section the button
37
+ // names, which is the thing a reader needs read out. The nav around
38
+ // it already says which module's sections these are.
39
+ , { "aria-haspopup": "menu", "aria-expanded": open, onClick: (event) => setAnchorEl(event.currentTarget), startIcon: activeItem?.icon ? _jsx(activeItem.icon, {}) : undefined, endIcon: _jsx(ExpandMoreIcon, {}), sx: { ...pillBase, ...activePill(accent), maxWidth: '100%', fontSize: 15 }, children: _jsx(Box, { component: "span", sx: { overflow: 'hidden', textOverflow: 'ellipsis' }, children: activeItem?.label ?? title }) })) : (items.map(({ label, route, icon: ItemIcon }) => {
40
+ const active = isSectionActive(route, currentRoute);
41
+ return (_jsx(Button, { component: Link, to: route, "aria-current": active ? 'page' : undefined, startIcon: ItemIcon ? _jsx(ItemIcon, {}) : undefined, sx: { ...pillBase, ...(active ? activePill(accent) : idlePill) }, children: label }, route));
42
+ })) }));
43
+ // The module is already named twice over on a page that has sections: the
44
+ // rail entry beside it is lit, and the section pill (or, on a phone, the
45
+ // dropdown) says which of its pages you are on. A third copy in the corner
46
+ // was just furniture. A module with no sections has neither, and a bar
47
+ // holding nothing but the tenant button reads as a mistake, so that one
48
+ // keeps its name.
49
+ const showTitle = items.length === 0;
50
+ return (_jsxs(Box, { component: "header", sx: { ...bar, minHeight: compact ? 56 : 68, px: compact ? 1.25 : 2 }, children: [showTitle &&
51
+ (lead ?? (_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1.25, minWidth: 0 }, children: [Icon && (_jsx(Box, { sx: { ...tile, color: accent, backgroundColor: `${accent}14` }, children: _jsx(Icon, { fontSize: "small" }) })), _jsx(Box, { component: "span", sx: {
52
+ fontSize: 17,
53
+ fontWeight: 700,
54
+ color: ink.strong,
55
+ whiteSpace: 'nowrap',
56
+ overflow: 'hidden',
57
+ textOverflow: 'ellipsis'
58
+ }, children: title })] }))), sections, _jsx(Box, { sx: { flex: 1, minWidth: 0 } }), end, items.length > 0 && (_jsx(Menu, { anchorEl: anchorEl, open: open, onClose: close, anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, transformOrigin: { vertical: 'top', horizontal: 'left' }, slotProps: {
59
+ paper: {
60
+ sx: {
61
+ mt: 0.5,
62
+ minWidth: 220,
63
+ borderRadius: 3,
64
+ border: `1px solid ${surface.divider}`,
65
+ boxShadow: '0 18px 40px -24px rgba(88,28,135,0.5)'
66
+ }
67
+ },
68
+ list: { 'aria-label': `${title} sections` }
69
+ }, children: items.map(({ label, route, icon: ItemIcon }) => {
70
+ const active = isSectionActive(route, currentRoute);
71
+ return (_jsxs(MenuItem, { component: Link, to: route, selected: active, "aria-current": active ? 'page' : undefined, onClick: close, sx: {
72
+ mx: 0.5,
73
+ borderRadius: 2,
74
+ minHeight: TOUCH_TARGET,
75
+ color: active ? accent : ink.body,
76
+ '&.Mui-selected': { backgroundColor: `${accent}14`, color: accent },
77
+ '&.Mui-selected:hover': { backgroundColor: `${accent}20` },
78
+ '&:hover': { backgroundColor: surface.sunken }
79
+ }, children: [ItemIcon && (_jsx(ListItemIcon, { sx: { color: 'inherit', minWidth: 36 }, children: _jsx(ItemIcon, { fontSize: "small" }) })), _jsx(ListItemText, { slotProps: { primary: { sx: { fontWeight: active ? 700 : 500 } } }, children: label })] }, route));
80
+ }) }))] }));
81
+ }
82
+ /**
83
+ * Frosted, like the navigation rail beside it, so the two read as one piece of
84
+ * chrome with the page washing through both.
85
+ */
86
+ const bar = {
87
+ flexShrink: 0,
88
+ position: 'sticky',
89
+ top: 0,
90
+ zIndex: 2,
91
+ display: 'flex',
92
+ alignItems: 'center',
93
+ gap: 1,
94
+ py: 1,
95
+ ...chrome,
96
+ borderBottom: `1px solid ${surface.divider}`
97
+ };
98
+ const tile = {
99
+ width: 34,
100
+ height: 34,
101
+ borderRadius: 2.5,
102
+ display: 'grid',
103
+ placeItems: 'center'
104
+ };
105
+ const sectionRow = {
106
+ display: 'flex',
107
+ alignItems: 'center',
108
+ gap: 0.5,
109
+ minWidth: 0,
110
+ overflowX: 'auto',
111
+ // The row scrolls when a module has more sections than fit; a scrollbar
112
+ // across the chrome would read as a seam.
113
+ scrollbarWidth: 'none',
114
+ '&::-webkit-scrollbar': { display: 'none' }
115
+ };
116
+ const compactNav = { minWidth: 0, display: 'flex' };
117
+ const pillBase = {
118
+ flexShrink: 0,
119
+ minWidth: 0,
120
+ height: 36,
121
+ px: 1.75,
122
+ borderRadius: 999,
123
+ textTransform: 'none',
124
+ fontSize: 14.5,
125
+ whiteSpace: 'nowrap',
126
+ transition: 'background-color .2s ease, color .2s ease',
127
+ '& .MuiButton-startIcon svg': { fontSize: 19 },
128
+ '& .MuiButton-startIcon, & .MuiButton-endIcon': { flexShrink: 0 }
129
+ };
130
+ const idlePill = {
131
+ fontWeight: 500,
132
+ color: ink.body,
133
+ '&:hover': { backgroundColor: surface.sunken, color: ink.strong }
134
+ };
135
+ /** The section being shown, and the compact dropdown that stands in for it. */
136
+ const activePill = (accent) => ({
137
+ fontWeight: 700,
138
+ color: accent,
139
+ backgroundColor: `${accent}14`,
140
+ boxShadow: `inset 0 0 0 1px ${accent}29`,
141
+ '&:hover': { backgroundColor: `${accent}20` }
142
+ });
143
+ export default SectionBar;
@@ -0,0 +1,2 @@
1
+ export { SectionBar, default } from './SectionBar';
2
+ export type { SectionBarProps, SectionBarItem } from './SectionBar';
@@ -0,0 +1 @@
1
+ export { SectionBar, default } from './SectionBar';
@@ -0,0 +1,5 @@
1
+ import React from 'react';
2
+ export interface UnauthorizedPageProps {
3
+ onLogin: () => void;
4
+ }
5
+ export declare const UnauthorizedPage: React.FC<UnauthorizedPageProps>;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Typography, Button, Container, Paper } from '@mui/material';
3
+ import { Lock, Login } from '@mui/icons-material';
4
+ export const UnauthorizedPage = ({ onLogin }) => {
5
+ return (_jsx(Container, { maxWidth: "sm", sx: { mt: 8 }, children: _jsxs(Paper, { elevation: 3, sx: { p: 4, textAlign: 'center' }, children: [_jsx(Lock, { sx: { fontSize: 64, color: 'primary.main', mb: 2 } }), _jsx(Typography, { variant: "h4", component: "h1", gutterBottom: true, color: "primary", children: "Access Restricted" }), _jsx(Typography, { variant: "body1", sx: {
6
+ color: 'text.secondary',
7
+ mb: 2
8
+ }, children: "You need to be logged in to access the Tumbaland." }), _jsx(Box, { sx: { mt: 4 }, children: _jsx(Button, { variant: "contained", size: "large", startIcon: _jsx(Login, {}), onClick: onLogin, sx: { px: 4, py: 1.5 }, children: "Sign In to Continue" }) })] }) }));
9
+ };
@@ -0,0 +1,2 @@
1
+ export { UnauthorizedPage } from './UnauthorizedPage';
2
+ export type { UnauthorizedPageProps } from './UnauthorizedPage';
@@ -0,0 +1 @@
1
+ export { UnauthorizedPage } from './UnauthorizedPage';
@@ -0,0 +1,35 @@
1
+ export interface UsageMeterProps {
2
+ /** What is being metered, e.g. "Storage" or "Tracked tickers". */
3
+ label: string;
4
+ used: number;
5
+ /** `-1` (or any negative) means no ceiling. */
6
+ limit: number;
7
+ /** Bytes are humanized; counts are printed as-is. */
8
+ unit?: 'bytes' | 'count';
9
+ /** Shown once the meter is at or past the warning threshold. */
10
+ onUpgrade?: () => void;
11
+ upgradeLabel?: string;
12
+ /** Tighter spacing, for a meter sitting inside a list row rather than a card. */
13
+ dense?: boolean;
14
+ }
15
+ /**
16
+ * Humanizes to the largest unit that keeps the number readable.
17
+ *
18
+ * Deliberately binary (1 KB = 1024 B) to match the limits, which are set in
19
+ * powers of two — `1 * GB` in `DEFAULT_PLAN_LIMITS` is 1073741824, and
20
+ * rendering that as "1.07 GB" against a "1 GB" plan would look like an error.
21
+ */
22
+ export declare function formatBytes(bytes: number): string;
23
+ /**
24
+ * A labelled usage bar: what you have used, out of what your plan allows.
25
+ *
26
+ * The single highest-leverage piece of the paywall — people upgrade when they
27
+ * can see the ceiling coming, not when they hit it. It is also what keeps
28
+ * enforcement from reading as breakage: a 402 with no meter beside it is
29
+ * indistinguishable from a bug.
30
+ *
31
+ * Purely presentational. It takes numbers, never fetches them, so the same
32
+ * component serves the album dashboard, the ticker list, the group member list
33
+ * and the pricing table without any of them agreeing on a data layer.
34
+ */
35
+ export declare function UsageMeter({ label, used, limit, unit, onUpgrade, upgradeLabel, dense }: UsageMeterProps): import("react").JSX.Element;
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Button, LinearProgress, Tooltip, Typography } from '@mui/material';
3
+ import { isUnlimited, usageRatio } from '../../entitlementsService';
4
+ /** Fraction of a meter above which the bar turns amber. */
5
+ const WARN_AT = 0.8;
6
+ /**
7
+ * Humanizes to the largest unit that keeps the number readable.
8
+ *
9
+ * Deliberately binary (1 KB = 1024 B) to match the limits, which are set in
10
+ * powers of two — `1 * GB` in `DEFAULT_PLAN_LIMITS` is 1073741824, and
11
+ * rendering that as "1.07 GB" against a "1 GB" plan would look like an error.
12
+ */
13
+ export function formatBytes(bytes) {
14
+ if (!Number.isFinite(bytes) || bytes <= 0)
15
+ return '0 B';
16
+ const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
17
+ const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
18
+ const value = bytes / Math.pow(1024, exponent);
19
+ // Whole units read better without a trailing ".00"; fractions need a digit.
20
+ const decimals = exponent === 0 || Number.isInteger(value) ? 0 : value < 10 ? 2 : 1;
21
+ return `${value.toFixed(decimals)} ${units[exponent]}`;
22
+ }
23
+ function formatValue(value, unit) {
24
+ return unit === 'bytes' ? formatBytes(value) : value.toLocaleString();
25
+ }
26
+ /**
27
+ * A labelled usage bar: what you have used, out of what your plan allows.
28
+ *
29
+ * The single highest-leverage piece of the paywall — people upgrade when they
30
+ * can see the ceiling coming, not when they hit it. It is also what keeps
31
+ * enforcement from reading as breakage: a 402 with no meter beside it is
32
+ * indistinguishable from a bug.
33
+ *
34
+ * Purely presentational. It takes numbers, never fetches them, so the same
35
+ * component serves the album dashboard, the ticker list, the group member list
36
+ * and the pricing table without any of them agreeing on a data layer.
37
+ */
38
+ export function UsageMeter({ label, used, limit, unit = 'count', onUpgrade, upgradeLabel = 'Upgrade', dense = false }) {
39
+ const unlimited = isUnlimited(limit);
40
+ const ratio = usageRatio(used, limit);
41
+ const atLimit = !unlimited && used >= limit;
42
+ const warning = !unlimited && ratio >= WARN_AT;
43
+ const color = atLimit ? 'error' : warning ? 'warning' : 'primary';
44
+ const summary = unlimited
45
+ ? `${formatValue(used, unit)} used`
46
+ : `${formatValue(used, unit)} of ${formatValue(limit, unit)}`;
47
+ return (_jsxs(Box, { sx: { width: '100%', mb: dense ? 1 : 2 }, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 1, mb: 0.5 }, children: [_jsx(Typography, { variant: dense ? 'caption' : 'body2', sx: { color: 'text.secondary', fontWeight: 500 }, children: label }), _jsx(Typography, { variant: dense ? 'caption' : 'body2', sx: { fontWeight: 600, color: atLimit ? 'error.main' : 'text.primary' }, children: summary })] }), unlimited ? (_jsx(Typography, { variant: "caption", sx: { color: 'text.secondary' }, children: "Unlimited on your plan" })) : (_jsx(Tooltip, { title: `${Math.round(ratio * 100)}% of ${label.toLowerCase()} used`, children: _jsx(LinearProgress, { variant: "determinate", value: ratio * 100, color: color, "aria-label": label, "aria-valuenow": used, "aria-valuemin": 0, "aria-valuemax": limit, sx: { height: dense ? 4 : 8, borderRadius: 4 } }) })), onUpgrade && warning && (_jsxs(Box, { sx: { mt: 1, display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(Typography, { variant: "caption", sx: { color: atLimit ? 'error.main' : 'warning.main' }, children: atLimit
48
+ ? `You have reached your ${label.toLowerCase()} limit.`
49
+ : `You are close to your ${label.toLowerCase()} limit.` }), _jsx(Button, { size: "small", variant: "text", onClick: onUpgrade, children: upgradeLabel })] }))] }));
50
+ }
@@ -0,0 +1,4 @@
1
+ export { UsageMeter, formatBytes } from './UsageMeter';
2
+ export type { UsageMeterProps } from './UsageMeter';
3
+ export { useEntitlements } from './useEntitlements';
4
+ export type { UseEntitlementsResult } from './useEntitlements';
@@ -0,0 +1,2 @@
1
+ export { UsageMeter, formatBytes } from './UsageMeter';
2
+ export { useEntitlements } from './useEntitlements';
@@ -0,0 +1,27 @@
1
+ import type { Entitlements } from '../../entitlementsService';
2
+ export interface UseEntitlementsResult {
3
+ entitlements: Entitlements | null;
4
+ loading: boolean;
5
+ /** Set when the lookup failed. The UI should hide the meter, not show an error. */
6
+ error: Error | null;
7
+ reload: () => void;
8
+ }
9
+ /**
10
+ * Loads the caller's plan, limits and live usage once per mount.
11
+ *
12
+ * Takes the fetcher rather than building it, because each front resolves
13
+ * `PAYMENT_API_URL` through its own `ConfigProvider` and this library has no
14
+ * access to any of them.
15
+ *
16
+ * **For the five fronts without `@tanstack/react-query`** — account, group,
17
+ * payment, relationship and shell. album-front and finance-front already have
18
+ * react-query and should use `useQuery` instead, which gives them caching and
19
+ * revalidation this deliberately does not try to reimplement.
20
+ *
21
+ * **Failure is not surfaced as an error state by design.** A meter is
22
+ * decoration on top of a page that works without it — if payment-service is
23
+ * unreachable, the album dashboard should still render albums. Callers check
24
+ * `entitlements` for null and omit the meter; nobody gets a red box because a
25
+ * usage bar could not load.
26
+ */
27
+ export declare function useEntitlements(fetcher: () => Promise<Entitlements>): UseEntitlementsResult;
@@ -0,0 +1,56 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ /**
3
+ * Loads the caller's plan, limits and live usage once per mount.
4
+ *
5
+ * Takes the fetcher rather than building it, because each front resolves
6
+ * `PAYMENT_API_URL` through its own `ConfigProvider` and this library has no
7
+ * access to any of them.
8
+ *
9
+ * **For the five fronts without `@tanstack/react-query`** — account, group,
10
+ * payment, relationship and shell. album-front and finance-front already have
11
+ * react-query and should use `useQuery` instead, which gives them caching and
12
+ * revalidation this deliberately does not try to reimplement.
13
+ *
14
+ * **Failure is not surfaced as an error state by design.** A meter is
15
+ * decoration on top of a page that works without it — if payment-service is
16
+ * unreachable, the album dashboard should still render albums. Callers check
17
+ * `entitlements` for null and omit the meter; nobody gets a red box because a
18
+ * usage bar could not load.
19
+ */
20
+ export function useEntitlements(fetcher) {
21
+ const [entitlements, setEntitlements] = useState(null);
22
+ const [loading, setLoading] = useState(true);
23
+ const [error, setError] = useState(null);
24
+ const [reloadToken, setReloadToken] = useState(0);
25
+ const reload = useCallback(() => setReloadToken((token) => token + 1), []);
26
+ useEffect(() => {
27
+ // Guards against a state update after unmount, and against a slow first
28
+ // response overwriting a fresher one after `reload`.
29
+ let active = true;
30
+ setLoading(true);
31
+ fetcher()
32
+ .then((result) => {
33
+ if (!active)
34
+ return;
35
+ setEntitlements(result);
36
+ setError(null);
37
+ })
38
+ .catch((err) => {
39
+ if (!active)
40
+ return;
41
+ setEntitlements(null);
42
+ setError(err);
43
+ })
44
+ .finally(() => {
45
+ if (active)
46
+ setLoading(false);
47
+ });
48
+ return () => {
49
+ active = false;
50
+ };
51
+ // `fetcher` is intentionally not a dependency: callers commonly pass an
52
+ // inline arrow, and depending on it would refetch on every render.
53
+ // eslint-disable-next-line react-hooks/exhaustive-deps
54
+ }, [reloadToken]);
55
+ return { entitlements, loading, error, reload };
56
+ }
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ export interface TenantOption {
3
+ id: string;
4
+ name: string;
5
+ type: 'personal' | 'group';
6
+ }
7
+ export declare const TenantProvider: React.FC<{
8
+ children: React.ReactNode;
9
+ }>;
10
+ export declare const useTenantListener: (callback: (tenant: TenantOption) => void) => void;
11
+ export declare const useTenantContext: () => TenantOption | null;
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState, useCallback, createContext, useContext, useEffect } from 'react';
3
+ import { logger } from '../../monitoring';
4
+ const TenantContext = createContext(null);
5
+ // Global listeners for tenant changes
6
+ const tenantChangeListeners = [];
7
+ export const TenantProvider = ({ children }) => {
8
+ const [globalTenant, setGlobalTenant] = useState(null);
9
+ const handleTenantChange = useCallback((tenant) => {
10
+ setGlobalTenant(tenant);
11
+ // Notify all registered listeners
12
+ tenantChangeListeners.forEach((listener) => {
13
+ try {
14
+ listener(tenant);
15
+ }
16
+ catch (error) {
17
+ logger.error('Error in tenant change listener', error);
18
+ }
19
+ });
20
+ }, []);
21
+ const registerTenantChangeListener = useCallback((callback) => {
22
+ tenantChangeListeners.push(callback);
23
+ // Return cleanup function
24
+ return () => {
25
+ const index = tenantChangeListeners.indexOf(callback);
26
+ if (index > -1) {
27
+ tenantChangeListeners.splice(index, 1);
28
+ }
29
+ };
30
+ }, []);
31
+ const contextValue = {
32
+ selectedTenant: globalTenant,
33
+ onTenantChange: handleTenantChange,
34
+ registerTenantChangeListener
35
+ };
36
+ return _jsx(TenantContext.Provider, { value: contextValue, children: children });
37
+ };
38
+ // Hook to listen for tenant changes in components
39
+ export const useTenantListener = (callback) => {
40
+ const context = useContext(TenantContext);
41
+ useEffect(() => {
42
+ if (!context) {
43
+ logger.warn('useTenantListener used outside TenantProvider');
44
+ return;
45
+ }
46
+ return context.registerTenantChangeListener(callback);
47
+ }, [context, callback]);
48
+ };
49
+ // Hook to get current tenant context
50
+ export const useTenantContext = () => {
51
+ const context = useContext(TenantContext);
52
+ return context?.selectedTenant || null;
53
+ };
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+ import type { SxProps, Theme } from '@mui/material';
3
+ export interface TenantOption {
4
+ id: string;
5
+ name: string;
6
+ type: 'personal' | 'group';
7
+ isSelected?: boolean;
8
+ }
9
+ export interface TenantSelectorProps {
10
+ /** Current selected tenant */
11
+ selectedTenant: TenantOption | null;
12
+ /** Available tenant options */
13
+ tenants: TenantOption[];
14
+ /** Callback when tenant is selected */
15
+ onTenantChange: (tenant: TenantOption) => void;
16
+ /** Loading state for fetching tenants */
17
+ loading?: boolean;
18
+ /** Error state */
19
+ error?: string | null;
20
+ /** Drops the "Viewing" caption and tightens the button, for a phone's bar. */
21
+ compact?: boolean;
22
+ /** Custom styling */
23
+ sx?: SxProps<Theme>;
24
+ }
25
+ /**
26
+ * Whose data the page is showing: the person's own, or one of their groups.
27
+ *
28
+ * Drawn as the same rounded pill the section bar beside it uses, in the brand
29
+ * violet rather than MUI's default blue, so the one control that changes what
30
+ * every module shows reads as part of the chrome instead of a stray form field.
31
+ */
32
+ declare const TenantSelector: React.FC<TenantSelectorProps>;
33
+ export default TenantSelector;