@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.
- package/dist/authService.d.ts +10 -0
- package/dist/authService.js +18 -3
- package/dist/components/AppErrorBoundary/AppErrorBoundary.d.ts +26 -0
- package/dist/components/AppErrorBoundary/AppErrorBoundary.js +25 -0
- package/dist/components/AppErrorBoundary/index.d.ts +2 -0
- package/dist/components/AppErrorBoundary/index.js +1 -0
- package/dist/components/AppLayout/AppLayout.d.ts +27 -0
- package/dist/components/AppLayout/AppLayout.js +45 -0
- package/dist/components/AppLayout/index.d.ts +2 -0
- package/dist/components/AppLayout/index.js +1 -0
- package/dist/components/AuthHeader/AuthHeader.d.ts +57 -0
- package/dist/components/AuthHeader/AuthHeader.js +135 -0
- package/dist/components/AuthHeader/index.d.ts +2 -0
- package/dist/components/AuthHeader/index.js +1 -0
- package/dist/components/ErrorPage/ErrorPage.d.ts +17 -0
- package/dist/components/ErrorPage/ErrorPage.js +19 -0
- package/dist/components/ErrorPage/index.d.ts +2 -0
- package/dist/components/ErrorPage/index.js +1 -0
- package/dist/components/Loader/Loader.d.ts +12 -0
- package/dist/components/Loader/Loader.js +15 -0
- package/dist/components/Loader/index.d.ts +2 -0
- package/dist/components/Loader/index.js +1 -0
- package/dist/components/LoginButton/LoginButton.d.ts +5 -0
- package/dist/components/LoginButton/LoginButton.js +15 -0
- package/dist/components/LoginButton/index.d.ts +2 -0
- package/dist/components/LoginButton/index.js +1 -0
- package/dist/components/NotificationsMenu/NotificationsMenu.d.ts +9 -0
- package/dist/components/NotificationsMenu/NotificationsMenu.js +29 -0
- package/dist/components/ProtectedRoute/ProtectedRoute.d.ts +33 -0
- package/dist/components/ProtectedRoute/ProtectedRoute.js +50 -0
- package/dist/components/ProtectedRoute/index.d.ts +2 -0
- package/dist/components/ProtectedRoute/index.js +1 -0
- package/dist/components/SectionBar/SectionBar.d.ts +49 -0
- package/dist/components/SectionBar/SectionBar.js +143 -0
- package/dist/components/SectionBar/index.d.ts +2 -0
- package/dist/components/SectionBar/index.js +1 -0
- package/dist/components/UnauthorizedPage/UnauthorizedPage.d.ts +5 -0
- package/dist/components/UnauthorizedPage/UnauthorizedPage.js +9 -0
- package/dist/components/UnauthorizedPage/index.d.ts +2 -0
- package/dist/components/UnauthorizedPage/index.js +1 -0
- package/dist/components/UsageMeter/UsageMeter.d.ts +35 -0
- package/dist/components/UsageMeter/UsageMeter.js +50 -0
- package/dist/components/UsageMeter/index.d.ts +4 -0
- package/dist/components/UsageMeter/index.js +2 -0
- package/dist/components/UsageMeter/useEntitlements.d.ts +27 -0
- package/dist/components/UsageMeter/useEntitlements.js +56 -0
- package/dist/components/tenant/TenantProvider.d.ts +11 -0
- package/dist/components/tenant/TenantProvider.js +53 -0
- package/dist/components/tenant/TenantSelector.d.ts +33 -0
- package/dist/components/tenant/TenantSelector.js +88 -0
- package/dist/components/tenant/index.d.ts +5 -0
- package/dist/components/tenant/index.js +3 -0
- package/dist/components/tenant/useTenant.d.ts +15 -0
- package/dist/components/tenant/useTenant.js +278 -0
- package/dist/components/tenant/useTenantDataRefresh.d.ts +6 -0
- package/dist/components/tenant/useTenantDataRefresh.js +28 -0
- package/dist/config/createConfigProvider.d.ts +44 -0
- package/dist/config/createConfigProvider.js +92 -0
- package/dist/federation.d.ts +46 -0
- package/dist/federation.js +37 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +35 -1
- package/dist/monitoring/batch.js +3 -3
- package/dist/monitoring/config.js +1 -4
- package/dist/monitoring/console.js +1 -1
- package/dist/plansService.js +1 -1
- package/dist/routing/embedContext.d.ts +31 -0
- package/dist/routing/embedContext.js +63 -0
- package/dist/routing/index.d.ts +12 -0
- package/dist/routing/index.js +12 -0
- package/dist/sessionStorage.d.ts +1 -1
- package/dist/sessionStorage.js +1 -1
- package/dist/theme/createTumbalandTheme.d.ts +38 -0
- package/dist/theme/createTumbalandTheme.js +245 -0
- package/dist/theme/index.d.ts +2 -0
- package/dist/theme/index.js +2 -0
- package/dist/theme/tokens.d.ts +186 -0
- package/dist/theme/tokens.js +182 -0
- package/package.json +28 -2
- package/dist/test/setup.d.ts +0 -9
- 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 @@
|
|
|
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 @@
|
|
|
1
|
+
export { SectionBar, default } from './SectionBar';
|
|
@@ -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 @@
|
|
|
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,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;
|