diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 850f69ca02..12ad26ceec 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -5057,6 +5057,14 @@ title = "Upload from Mobile" tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide" title = "PDF Multi Tool" +[navFooter] +openEditor = "Open PDF Editor" +openProcessor = "Open PDF Processor" + +[navFooter.credits] +count = "{{remaining}} of {{total}}" +label = "Free credits" + [oauth.error] message = "Authentication was not successful. You can close this window and try again." title = "Authentication Failed" @@ -5621,8 +5629,8 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man freeTitle = "Unlimited PDF editing" [payg.free.hero] -barAria = "Free PDFs used" -capSuffix = "/ {{limit}} free PDFs" +barAria = "Free PDFs remaining" +capSuffix = "of {{limit}} free PDFs left" metaCategories = "Automation · AI · API requests" [payg.free.member] @@ -6685,12 +6693,12 @@ reachedTitle = "Monthly spend limit reached" title = "Couldn't open Stripe portal" [portal.billing.walletMeter] -barAria = "Free PDFs used" -capSuffix_one = "of {{allowance}} free PDFs used" -capSuffix_other = "of {{allowance}} free PDFs used" +barAria = "Free PDFs remaining" +capSuffix_one = "of {{allowance}} free PDF left" +capSuffix_other = "of {{allowance}} free PDFs left" eyebrow = "Processor trial" -statusLabel_one = "{{remaining}} left" -statusLabel_other = "{{remaining}} left" +statusLabel_one = "{{used}} used" +statusLabel_other = "{{used}} used" sub = "Use the PDF Editor for free. Pay to process PDFs automatically." title_one = "Process {{allowance}} PDFs free" title_other = "Process {{allowance}} PDFs free" @@ -7665,7 +7673,6 @@ integrations = "Integrations" pipelines = "Pipelines" policies = "Policies" procurement = "Procurement" -settings = "Settings" sources = "Sources" usage = "Usage & Billing" users = "Users" diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx index 8811e537b2..c4407cae7f 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx @@ -12,6 +12,7 @@ import { formatPeriodDate, MeterBar, meterState, + remainingMeter, } from "@app/billing"; import "@app/components/shared/config/configSections/Payg.css"; import "@app/components/shared/config/configSections/PaygFree.css"; @@ -48,7 +49,8 @@ export function useFreeSnapshot(): FreeSnapshot { export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { const { t } = useTranslation(); - const { state, pct } = meterState(snap.billableUsed, snap.billableLimit); + const remaining = Math.max(0, snap.billableLimit - snap.billableUsed); + const { state, pct } = remainingMeter(remaining, snap.billableLimit); const stateLabel = state === "DEGRADED" ? t("payg.free.state.limitReached", "Limit reached") @@ -60,9 +62,9 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { { + if (live !== undefined) writeCachedCredits(live); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wallet]); + + return (live !== undefined ? live : seed) ?? null; +} diff --git a/frontend/editor/src/cloud/hooks/useOpenPlan.ts b/frontend/editor/src/cloud/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..4d532319f6 --- /dev/null +++ b/frontend/editor/src/cloud/hooks/useOpenPlan.ts @@ -0,0 +1,13 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +/** + * Cloud editor builds open the settings modal on its Plan section, which is + * where the free grant is explained and the Processor plan is switched on. + * Routed rather than called directly because the modal is URL-driven here + * (`/settings/*`), the same path the admin tour uses to open it. + */ +export function useOpenPlan(): (() => void) | null { + const navigate = useNavigate(); + return useCallback(() => navigate("/settings/plan"), [navigate]); +} diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts index 0a3f78b3ce..ed3cb2ce6b 100644 --- a/frontend/editor/src/cloud/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -32,6 +32,14 @@ * promise see the UI flip exactly once the new state is visible — no * intermediate flash of the old value. * + *

Freshness

+ * + * The figures drain as metered work runs, so a mounted consumer re-reads the + * wallet every {@link WALLET_POLL_MS} and again whenever the tab regains + * visibility. Those refreshes are silent — they leave {@code loading} and + * {@code error} alone and only commit fresher data — so consumers that gate on + * those flags don't flicker on a background tick. + * *

Dev preview fallback

* * When the hook is rendered outside the saas app (e.g. on {@code @@ -178,6 +186,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet { return prev; } +/** + * How often a mounted consumer re-reads the wallet. Matches the app query + * client's staleTime, so the sidebar meter and anything cached elsewhere age + * out on the same clock. + */ +const WALLET_POLL_MS = 30_000; + export function useWallet(): UseWalletResult { // Resolved once: the dev-preview side-channel when rendered outside the real // app (saas /dev/payg-preview route), else null (every real build + desktop). @@ -201,13 +216,29 @@ export function useWallet(): UseWalletResult { // "the request fired." Cleared when no load is pending. const inFlight = useRef | null>(null); + // Set for refreshes the user didn't ask for (the poll below). Silence governs + // whether a load may RAISE `loading` / `error`, never whether it may clear + // them: consumers gate on both — the limit modals do + // `if (loading || !wallet) return null`, and Plan swaps in an error alert — + // so a background tick must not blink an open modal out or replace a working + // page over a transient failure. Clearing is always the latest request's job, + // silent or not; a silent load that skipped the clear would strand `loading` + // true after superseding a visible one, which suppresses those modals for the + // rest of the session. + const silentRefresh = useRef(false); + useEffect(() => { const reqId = ++latestReqId.current; let cancelled = false; + const silent = silentRefresh.current; + silentRefresh.current = false; + const promise = (async () => { - setLoading(true); - setError(null); + if (!silent) { + setLoading(true); + setError(null); + } if (devPreview) { const synth = devPreview.buildWallet(devPreview.role()); @@ -221,11 +252,22 @@ export function useWallet(): UseWalletResult { const res = await apiClient.get("/api/v1/payg/wallet"); if (cancelled || reqId !== latestReqId.current) return; setWallet((prev) => reuseIfEqual(prev, res.data)); + // Fresh data retires any earlier failure, including one a silent poll + // is recovering from — otherwise Plan keeps its alert over good data. + setError(null); } catch (e: unknown) { if (cancelled || reqId !== latestReqId.current) return; - console.warn("[useWallet] fetch failed", e); - setError(e instanceof Error ? e.message : "Failed to load wallet"); + if (!silent) { + console.warn("[useWallet] fetch failed", e); + setError(e instanceof Error ? e.message : "Failed to load wallet"); + } + // A failed background refresh is a non-event: the last good snapshot + // stands and the next tick self-heals, so it neither surfaces nor + // logs — otherwise an offline tab warns every WALLET_POLL_MS. } finally { + // Deliberately not gated on `silent`: whichever load is latest owns + // settling the flag, or a silent refresh that supersedes a visible one + // leaves it stuck true. if (!cancelled && reqId === latestReqId.current) { setLoading(false); } @@ -242,6 +284,46 @@ export function useWallet(): UseWalletResult { }; }, [devPreview, refetchTick]); + // The wallet drains as automation, AI and API work runs, so a figure fetched + // on mount goes stale while the user watches it. Refresh on a timer, and + // immediately on returning to the tab — coming back to a stale number is the + // case people actually notice. Hidden tabs don't poll, and the dev-preview + // wallet is synthesised locally so there is nothing to re-read. + useEffect(() => { + if (devPreview) return; + + let timer: ReturnType | undefined; + const refresh = () => { + silentRefresh.current = true; + setRefetchTick((t) => t + 1); + }; + const stop = () => { + if (timer !== undefined) { + clearInterval(timer); + timer = undefined; + } + }; + const start = () => { + stop(); + timer = setInterval(refresh, WALLET_POLL_MS); + }; + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + refresh(); + start(); + } else { + stop(); + } + }; + + if (document.visibilityState === "visible") start(); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + stop(); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [devPreview]); + const refetch = useCallback(async () => { setRefetchTick((t) => t + 1); // Snapshot the next-tick promise so the caller awaits this refetch diff --git a/frontend/editor/src/core/components/shared/BrandMark.css b/frontend/editor/src/core/components/shared/BrandMark.css index 7ddff9b4c7..df05ff1307 100644 --- a/frontend/editor/src/core/components/shared/BrandMark.css +++ b/frontend/editor/src/core/components/shared/BrandMark.css @@ -48,9 +48,55 @@ transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428); } +/* One-shot "thinking" drift — the two parallelograms swap past each other and + settle back. Same motion the chat FAB loops while the agent works, but this + pair starts and ends at rest (translate 0, full opacity) so a single + iteration can end without snapping. Callers apply it for one beat; see + NavFooter.css for the hover use. */ +@keyframes sui-brandmark-drift-a { + 0%, + 100% { + transform: translate(0, 0); + opacity: 1; + } + 25% { + transform: translate(-1px, -5px); + opacity: 0.55; + } + 50% { + transform: translate(-6px, 0); + opacity: 0.9; + } + 75% { + transform: translate(-1px, 5px); + opacity: 0.6; + } +} + +@keyframes sui-brandmark-drift-b { + 0%, + 100% { + transform: translate(0, 0); + opacity: 1; + } + 25% { + transform: translate(1px, 5px); + opacity: 0.85; + } + 50% { + transform: translate(6px, 0); + opacity: 0.5; + } + 75% { + transform: translate(1px, -5px); + opacity: 0.85; + } +} + @media (prefers-reduced-motion: reduce) { .sui-brandmark__a, .sui-brandmark__b { transition: none; + animation: none; } } diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 590f59fa7d..2347d9a2b2 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -75,16 +75,13 @@ padding: 0.25rem 0; overflow: hidden; } -.file-sidebar-footer-box { - padding: 0.25rem 0; - flex-shrink: 0; -} +/* The footer is the shared : it brings its own boxes and padding, + so this class only positions it in the column. */ /* Collapsed rail: the file tree isn't rendered, so hide its (empty) box and let the boxes stack at the top — controls, then the settings footer right after — instead of the files box stretching to fill. */ -.file-sidebar[data-collapsed="true"] .file-sidebar-controls, -.file-sidebar[data-collapsed="true"] .file-sidebar-footer-box { +.file-sidebar[data-collapsed="true"] .file-sidebar-controls { padding: 0.25rem; } .file-sidebar[data-collapsed="true"] .file-sidebar-files-box { @@ -538,86 +535,3 @@ pointer-events: none; animation: none; } - -/* ---- Bottom bar (user + settings) ---- */ -.file-sidebar-bottom-bar { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 6px; - flex-shrink: 0; - min-height: 40px; -} - -/* Bottom bar settings icon tracks the right edge during collapse animation */ - -.file-sidebar-bottom-avatar { - width: 28px; - height: 28px; - border-radius: 50%; - background-color: var(--c-accent-text); - color: var(--c-text-on-primary); - font-size: 12px; - font-weight: 600; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - user-select: none; - overflow: hidden; -} - -/* No colored disc behind an actual photo; keep it for the initials fallback. */ -.file-sidebar-bottom-avatar--picture { - background-color: transparent; -} - -.file-sidebar-bottom-avatar-img { - width: 100%; - height: 100%; - border-radius: 50%; - object-fit: cover; -} - -.file-sidebar-bottom-name { - flex: 1; - font-size: 13px; - font-weight: 500; - color: var(--c-text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -.file-sidebar-bottom-bar[role="button"]:hover { - background-color: var(--c-hover); -} - -.file-sidebar-bottom-bar[role="button"]:focus-visible { - outline: 2px solid var(--c-primary); - outline-offset: -2px; -} - -.file-sidebar-bottom-settings { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 6px; - color: var(--c-text-subtle); - padding: 0; - flex-shrink: 0; - margin-left: auto; -} - -.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-settings { - width: 32px; - height: 32px; -} - -.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-bar { - justify-content: center; - padding: 8px 0; -} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 2514e7f556..1c06236027 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -22,13 +22,15 @@ import { } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; -import { useAuth } from "@app/auth/UseSession"; -import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl"; +import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; +import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary"; +import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; +import { useOpenPlan } from "@app/hooks/useOpenPlan"; +import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; import { useIndexedDB, useIndexedDBRevision, } from "@app/contexts/IndexedDBContext"; -import { accountService } from "@app/services/accountService"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; import { AppSwitcher } from "@app/components/shared/AppSwitcher"; import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; @@ -37,8 +39,7 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import FolderSpecialIcon from "@mui/icons-material/FolderSpecial"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import AddIcon from "@mui/icons-material/Add"; -import OpenInNewIcon from "@mui/icons-material/OpenInNew"; -import SettingsIcon from "@mui/icons-material/Settings"; +import OpenInFullIcon from "@mui/icons-material/OpenInFull"; import type { FileId } from "@app/types/file"; import { FileItem } from "@app/components/shared/FileSidebarFileItem"; import { useLabelName } from "@app/data/labelDisplay"; @@ -241,43 +242,11 @@ const FileSidebar = forwardRef( const { addFiles } = useFileHandler(); const indexedDB = useIndexedDB(); - // Each auth layer derives its own displayName from its native user shape. - // Fall back to the proprietary REST endpoint only when the auth - // context yields nothing - then to "User" as a generic last resort. - const { displayName: authDisplayName, isAnonymous } = useAuth(); - const [accountUsername, setAccountUsername] = useState(null); - const displayName = - authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"); - - const profilePictureUrl = useProfilePictureUrl(); - const [pictureFailed, setPictureFailed] = useState(false); - useEffect(() => setPictureFailed(false), [profilePictureUrl]); - const showProfilePicture = !!profilePictureUrl && !pictureFailed; - - useEffect(() => { - if (!config?.enableLogin) { - setAccountUsername(null); - return; - } - if (authDisplayName) { - // The auth context has a name; don't bother hitting the REST - // endpoint, but clear any stale cached value from a prior call. - setAccountUsername(null); - return; - } - accountService - .getAccountData() - .then((data) => { - // Always reflect the latest result - including clearing it on - // sign-out, when the endpoint returns no username (or 401s into - // the catch branch below). Without this, signing out would leave - // the old username on screen. - setAccountUsername(data?.username ?? null); - }) - .catch(() => { - setAccountUsername(null); - }); - }, [config?.enableLogin, authDisplayName]); + const { displayName, profilePictureUrl, isAnonymous } = + useAccountIdentity(); + const credits = useFreeCreditsSummary(); + const otherApp = useOtherAppSwitch(); + const openPlan = useOpenPlan(); // Leaf files = user-visible files (excludes intermediate tool outputs) const [allFileStubs, setAllFileStubs] = useState([]); @@ -1115,7 +1084,7 @@ const FileSidebar = forwardRef( )} data-testid="open-files-page" > - + ( {/* Getting-started checklist, floating above the footer (SaaS only). */} - {/* Box 3 — account footer (avatar + name + settings). */} - - {/* Bottom bar: user name + settings */} - -
e.key === "Enter" && onOpenSettings() - : undefined - } - data-testid={onOpenSettings ? "config-button" : undefined} - data-tour={onOpenSettings ? "config-button" : undefined} - aria-label={ - onOpenSettings - ? t("fileSidebar.openSettings", "Open settings") - : displayName - } - style={onOpenSettings ? { cursor: "pointer" } : undefined} - > -
- {showProfilePicture ? ( - setPictureFailed(true)} - /> - ) : ( - displayName.charAt(0).toUpperCase() - )} -
- {!collapsed && ( - - {displayName} - - )} - {onOpenSettings && !collapsed && ( -
- -
- )} -
-
-
+ {/* Box 3 — the shared footer: credits, app switch, account row. */} + ); }, diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.css b/frontend/editor/src/core/components/shared/navFooter/NavFooter.css new file mode 100644 index 0000000000..aa2688cd35 --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.css @@ -0,0 +1,156 @@ +/* Shared sidebar footer: one surface holding the link-account CTA, the credits + meter, the other-app switch and the account row, hairline-separated. + Structural only — every colour comes from a --c-* semantic token. */ + +.nav-footer { + display: flex; + flex-direction: column; + flex-shrink: 0; + /* Vertical only: the slots carry the horizontal padding so their separator + runs the full width of the surface. */ + padding: 0.25rem 0; + overflow: hidden; +} + +.nav-footer__slot { + padding-inline: 0.375rem; +} + +/* Separators are drawn by the slots themselves, never as their own elements. + A slot whose contents render nothing (the link-account CTA returns null once + the org is linked, and an element is truthy even when it renders null) is + :empty, so it is skipped by both rules below — it can't leave a line behind, + and it can't push one to the top or bottom of the surface. A rule that only + ever matches a slot PRECEDED by another visible slot cannot draw a leading + separator, whatever the caller passes in. */ +.nav-footer__slot:empty { + display: none; +} + +.nav-footer__slot:not(:empty) ~ .nav-footer__slot:not(:empty) { + border-top: 1px solid var(--c-border-subtle); + margin-top: 0.25rem; + padding-top: 0.25rem; +} + +/* Fades the rows up on the first footer mount of a page session only. They are + seeded from cache, so they're already present at first paint; replaying this + on every later mount (switching apps, remounting a view) would animate + content that never changed and read as a twitch. */ +@keyframes nav-footer-row-in { + from { + opacity: 0; + transform: translateY(0.25rem); + } + to { + opacity: 1; + transform: none; + } +} + +.nav-footer[data-animate] .nav-footer__slot:not(:empty) { + animation: nav-footer-row-in var(--motion-enter) both; +} + +@media (prefers-reduced-motion: reduce) { + .nav-footer[data-animate] .nav-footer__slot:not(:empty) { + animation: none; + } +} + +/* ---- Rows (link-account, credits, switch, account) ---- */ + +.nav-footer__row { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-height: 2.25rem; + padding: 0.25rem 0.375rem; + border: 0; + border-radius: 0.5rem; + background: none; + color: var(--c-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.nav-footer__row:disabled { + cursor: default; +} + +.nav-footer__row:not(:disabled):hover { + background-color: var(--c-hover); +} + +.nav-footer__row:focus-visible { + outline: 2px solid var(--c-primary); + outline-offset: -2px; +} + +.nav-footer__row-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1.625rem; +} + +/* Hovering the switch row plays the mark's "thinking" drift once — the same + motion the chat FAB loops, for a single beat, as a hint that the row hands + off to the other app. One iteration only: it starts and ends at rest, so + nothing snaps when it finishes, and re-entering the row replays it. */ +.nav-footer__row:hover .sui-brandmark__a { + animation: sui-brandmark-drift-a 1.1s ease-in-out 1; +} +.nav-footer__row:hover .sui-brandmark__b { + animation: sui-brandmark-drift-b 1.1s ease-in-out 1; +} + +.nav-footer__row-label { + flex: 1; + min-width: 0; + font-size: 0.8125rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Trailing affordance on a row: the account row's gear, the switch row's + leaving-this-app arrow. */ +.nav-footer__trailing { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + color: var(--c-text-subtle); +} + +/* Rows contributed by a caller (the link-account NavItem) sit in the same + surface, so match this footer's row metrics rather than the nav rail's. */ +.nav-footer .sui-navitem { + min-height: 2.25rem; + padding: 0.25rem 0.375rem; + margin: 0; + border-radius: 0.5rem; + font-size: 0.8125rem; +} + +/* ---- Collapsed icon rail ---- */ + +.nav-footer[data-collapsed] .nav-footer__slot { + padding-inline: 0.25rem; +} + +.nav-footer[data-collapsed] .nav-footer__row { + justify-content: center; + padding-inline: 0; +} + +.nav-footer[data-collapsed] .sui-navitem { + justify-content: center; + padding-inline: 0; +} diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx new file mode 100644 index 0000000000..31a04be596 --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LinkIcon from "@mui/icons-material/Link"; +import { NavItem } from "@app/ui/NavItem"; +import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; + +/** Stands in for a CTA that has decided it has nothing to show. */ +function RendersNothing() { + return null; +} + +const meta: Meta = { + title: "Shared/NavFooter", + component: NavFooter, + parameters: { layout: "padded" }, + args: { + displayName: "admin", + onOpenSettings: () => {}, + credits: { remaining: 247, total: 500 }, + onOpenPlan: () => {}, + otherApp: { app: "processor", onOpen: () => {} }, + }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** The editor's footer: credits, "Open PDF Processor", the account row. */ +export const InEditor: Story = {}; + +/** The processor's footer. Same three boxes, opposite switch target. */ +export const InProcessor: Story = { + args: { otherApp: { app: "editor", onOpen: () => {} } }, +}; + +/** Self-hosted processor: no wallet, so no meter, and the link-account CTA + * rides along in the account box. */ +export const WithLinkAccountCta: Story = { + args: { + credits: null, + otherApp: { app: "editor", onOpen: () => {} }, + accountExtras: ( + } + /> + ), + }, +}; + +/** Regression guard: the processor always passes its link-account CTA, but that + * component renders null once the org is linked. An element is truthy even + * when it renders nothing, so this must not leave a separator above the first + * visible row. */ +export const ExtrasThatRenderNothing: Story = { + args: { accountExtras: }, +}; + +/** A real profile picture replaces the initials disc. */ +export const WithProfilePicture: Story = { + args: { + displayName: "Ada Lovelace", + profilePictureUrl: + "data:image/svg+xml;utf8," + + encodeURIComponent( + '', + ), + }, +}; + +/** Credits running low — the dot and bar shift to the warning tone at 20% left. */ +export const CreditsLow: Story = { + args: { credits: { remaining: 42, total: 500 } }, +}; + +/** Allowance exhausted. */ +export const CreditsExhausted: Story = { + args: { credits: { remaining: 0, total: 500 } }, +}; + +/** Core OSS: no wallet, no second app, settings only. */ +export const MinimalBuild: Story = { + args: { credits: null, otherApp: null }, +}; + +/** No settings handler — the account row is inert identity, not a button. */ +export const NoSettings: Story = { + args: { onOpenSettings: undefined }, +}; + +/** Collapsed icon rail: labels become tooltips. */ +export const Collapsed: Story = { + args: { collapsed: true }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx new file mode 100644 index 0000000000..a32063e56f --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { cleanup, render } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; + +/** The footer's tooltips need Mantine's theme context. */ +function withProviders(ui: React.ReactNode) { + return {ui}; +} + +function renderFooter() { + const { container } = render( + withProviders( + {}} + credits={{ remaining: 247, total: 500 }} + otherApp={{ app: "processor", onOpen: () => {} }} + />, + ), + ); + return container.querySelector(".nav-footer") as HTMLElement; +} + +describe("NavFooter — enter animation", () => { + it("plays once per page session, not on every remount", () => { + // The rows are seeded from cache, so they're present at first paint. Every + // later mount — switching apps, remounting a view — would otherwise replay + // the fade on content that never changed, which reads as a twitch. + expect(renderFooter().dataset.animate).toBe("true"); + cleanup(); + expect(renderFooter().dataset.animate).toBeUndefined(); + cleanup(); + expect(renderFooter().dataset.animate).toBeUndefined(); + }); +}); + +describe("NavFooter — separators", () => { + it("never renders a divider beside a row that renders nothing", () => { + // Dividers are CSS between adjacent non-empty slots, so an extras element + // that returns null (the linked org's link-account CTA) can't leave a line. + const { container } = render( + withProviders( + {}} + credits={null} + otherApp={null} + accountExtras={<>{null}} + />, + ), + ); + const slots = container.querySelectorAll(".nav-footer__slot"); + const filled = [...slots].filter((s) => s.childElementCount > 0); + expect(filled).toHaveLength(1); + expect(container.querySelectorAll(".nav-footer__divider")).toHaveLength(0); + }); +}); diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx new file mode 100644 index 0000000000..373c91bccf --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx @@ -0,0 +1,213 @@ +import { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import SettingsIcon from "@mui/icons-material/Settings"; +import { Avatar, NavSurface } from "@app/ui"; +import { BrandMark } from "@app/components/shared/BrandMark"; +import { type AppSwitchTarget } from "@app/components/shared/AppSwitch"; +import { + NavFooterCreditsRow, + type NavFooterCredits, +} from "@app/components/shared/navFooter/NavFooterCreditsRow"; +import "@app/components/shared/navFooter/NavFooter.css"; + +export interface NavFooterAppLink { + /** The app this footer is NOT in — the one the row opens. */ + app: AppSwitchTarget; + onOpen: () => void; +} + +export interface NavFooterProps { + /** Name shown next to the avatar, and the source of its initials fallback. */ + displayName: string; + /** Profile picture; initials are drawn when absent or the URL fails to load. */ + profilePictureUrl?: string | null; + /** Omit to render the account row as static text (no settings affordance). */ + onOpenSettings?: () => void; + /** Null/undefined hides the meter — builds with no wallet never show it. */ + credits?: NavFooterCredits | null; + /** Opens the plan surface from the credits row; omit to leave it inert. */ + onOpenPlan?: () => void; + /** Null/undefined hides the switch row — e.g. no access to the other app. */ + otherApp?: NavFooterAppLink | null; + /** Extra rows above the account row (the self-hosted link-account CTA). */ + accountExtras?: ReactNode; + /** Icon-rail state: labels collapse to tooltips. */ + collapsed?: boolean; + className?: string; +} + +/** + * Whether the enter animation has already played this page session. The rows + * are seeded from cache now, so they're present from first paint and every + * later mount — switching apps, remounting a view — would otherwise replay the + * animation on content that never changed, which reads as the UI twitching. + */ +let hasPlayedEnter = false; + +/** + * The bottom section every sidebar ends with, shared by the editor and the + * processor so both present the same rows. ONE surface, hairline-separated, in + * this order: + * + * 1. caller-contributed rows (the self-hosted link-account CTA) + * 2. free credits remaining + * 3. "Open " + * 4. the account row — avatar, name, settings + * + * Purely presentational: each app resolves its own identity, wallet and + * app-switch access and passes them in, so this file carries no build-specific + * gating. A row whose data is absent is dropped, and so is the separator that + * would have sat beside it. + */ +export function NavFooter({ + displayName, + profilePictureUrl, + onOpenSettings, + credits, + onOpenPlan, + otherApp, + accountExtras, + collapsed = false, + className, +}: NavFooterProps) { + const { t } = useTranslation(); + const [animate] = useState(() => { + if (hasPlayedEnter) return false; + hasPlayedEnter = true; + return true; + }); + + const settingsLabel = t("fileSidebar.openSettings", "Open settings"); + const accountLabel = onOpenSettings + ? `${displayName} - ${settingsLabel}` + : displayName; + + // One surface, hairline-separated rows. Each row gets a slot; the separators + // are drawn by CSS between adjacent NON-EMPTY slots (see NavFooter.css), so a + // row that renders nothing — the link-account CTA returns null once the org is + // linked, and an element is truthy even then — can't leave a line behind. + const rows: Array<{ key: string; node: ReactNode }> = []; + + if (accountExtras) rows.push({ key: "extras", node: accountExtras }); + + if (credits) { + rows.push({ + key: "credits", + node: ( + + ), + }); + } + + if (otherApp) { + rows.push({ + key: "switch", + node: ( + + + + ), + }); + } + + rows.push({ + key: "account", + node: ( + + + + ), + }); + + return ( + + {rows.map((row) => ( +
+ {row.node} +
+ ))} +
+ ); +} + +function openAppLabel( + app: AppSwitchTarget, + t: (key: string, fallback: string) => string, +): string { + return app === "editor" + ? t("navFooter.openEditor", "Open PDF Editor") + : t("navFooter.openProcessor", "Open PDF Processor"); +} diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css new file mode 100644 index 0000000000..b37def6b2d --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css @@ -0,0 +1,83 @@ +/* Free-credits meter inside the sidebar footer. The row base (padding, hover, + focus) comes from NavFooter.css; these rules are the meter itself. */ + +.nav-footer__credits { + flex-direction: column; + align-items: stretch; + gap: 0.375rem; + cursor: default; +} + +/* Inert by default, so it must not read as hoverable; the actionable variant + opts back into the shared row hover. */ +.nav-footer__credits:hover { + background: none; +} + +.nav-footer__credits--actionable { + cursor: pointer; +} +.nav-footer__credits--actionable:hover { + background-color: var(--c-hover); +} + +.nav-footer__credits-head { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; +} + +.nav-footer__dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + flex-shrink: 0; + background-color: var(--c-success); +} +.nav-footer__dot[data-tone="warning"] { + background-color: var(--c-warning); +} +.nav-footer__dot[data-tone="danger"] { + background-color: var(--c-danger); +} + +.nav-footer__credits-label { + flex: 1; + min-width: 0; + font-weight: 500; + color: var(--c-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nav-footer__credits-count { + flex-shrink: 0; + color: var(--c-text-muted); + font-variant-numeric: tabular-nums; +} + +/* ---- Collapsed rail ---- */ + +/* Rotated so the fill starts at 12 o'clock and runs clockwise. */ +.nav-footer__credits-ring { + width: 1.25rem; + height: 1.25rem; + margin-inline: auto; + transform: rotate(-90deg); +} + +.nav-footer__credits-ring-track, +.nav-footer__credits-ring-fill { + fill: none; + stroke-width: 3; +} + +.nav-footer__credits-ring-track { + stroke: var(--c-surface-sunken); +} + +.nav-footer__credits-ring-fill { + stroke-linecap: round; +} diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx new file mode 100644 index 0000000000..94e941d71c --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx @@ -0,0 +1,158 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import { ProgressBar } from "@app/ui"; +import "@app/components/shared/navFooter/NavFooterCreditsRow.css"; + +export interface NavFooterCredits { + /** Free credits still available to spend. */ + remaining: number; + /** Size of the free allowance — the "of N" denominator. */ + total: number; +} + +/** Remaining-credit bands, mirroring the usage meters' 80% / 100% thresholds. */ +function creditsTone(remaining: number, total: number): string { + if (remaining <= 0) return "danger"; + return total > 0 && remaining / total <= 0.2 ? "warning" : "success"; +} + +interface NavFooterCreditsRowProps { + credits: NavFooterCredits; + /** Icon rail: the figures drop and the bar alone carries the state. */ + collapsed: boolean; + /** Row label, passed in so the meter owns no copy of its own. */ + label: string; + /** Opens the plan surface. Omit to render the meter as inert text. */ + onOpen?: () => void; +} + +/** + * The free-credits meter as it appears in the sidebar footer: a state dot, the + * label, "X of Y" remaining, and a fill bar underneath. Figures are clamped + * here so a wallet that reports more remaining than the allowance (or negative) + * can't overflow the bar. + * + * Rendered as a {@code nav-footer__row}, so it inherits that row's metrics + * from NavFooter.css and only brings its own meter styling. + */ +export function NavFooterCreditsRow({ + credits, + collapsed, + label, + onOpen, +}: NavFooterCreditsRowProps) { + const { t } = useTranslation(); + + const total = Math.max(0, credits.total); + const remaining = Math.min(Math.max(0, credits.remaining), total); + const tone = creditsTone(remaining, total); + const count = t("navFooter.credits.count", "{{remaining}} of {{total}}", { + remaining: remaining.toLocaleString(), + total: total.toLocaleString(), + }); + + return ( + + + {collapsed ? ( + // The rail is one icon wide, so a full-width bar would read as a + // stray line; a ring carries the same fraction at icon size. + 0 ? remaining / total : 0} + tone={tone} + label={`${label}: ${count}`} + /> + ) : ( + <> +
+ + {label} + {count} +
+ 0 ? remaining / total : 0} + height={6} + color={`var(--c-${tone})`} + label={`${label}: ${count}`} + /> + + )} +
+
+ ); +} + +/** Icon-sized donut carrying the same remaining fraction as the expanded bar. */ +function CreditsRing({ + fraction, + tone, + label, +}: { + fraction: number; + tone: string; + label: string; +}) { + const RADIUS = 8; + const circumference = 2 * Math.PI * RADIUS; + const filled = Math.min(1, Math.max(0, fraction)) * circumference; + + return ( + + + + + ); +} + +/** + * The meter is a button only where there is a plan surface to open — otherwise + * it stays a plain div, so a build with nowhere to go doesn't advertise a + * click that does nothing. + */ +function Row({ + onOpen, + label, + children, +}: { + onOpen?: () => void; + label: string; + children: ReactNode; +}) { + const className = `nav-footer__row nav-footer__credits${ + onOpen ? " nav-footer__credits--actionable" : "" + }`; + if (!onOpen) return
{children}
; + return ( + + ); +} diff --git a/frontend/editor/src/core/hooks/useAccountIdentity.ts b/frontend/editor/src/core/hooks/useAccountIdentity.ts new file mode 100644 index 0000000000..026ac08dab --- /dev/null +++ b/frontend/editor/src/core/hooks/useAccountIdentity.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@app/auth/UseSession"; +import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { accountService } from "@app/services/accountService"; + +export interface AccountIdentity { + /** Never empty — falls back to a generic "User" so a row is never blank. */ + displayName: string; + profilePictureUrl: string | null; + isAnonymous: boolean; +} + +/** + * The signed-in identity as the UI should draw it: one name and one picture, + * resolved the same way everywhere. Every surface that shows "who am I" (the + * editor and processor sidebar footers, the account settings page) reads this, + * so a user can't see one initial in the sidebar and a different one in + * settings. + * + * Resolution order for the name: the auth layer's own displayName (each layer + * derives it from its native user shape), then the proprietary REST endpoint, + * then a generic last resort. + */ +export function useAccountIdentity(): AccountIdentity { + const { t } = useTranslation(); + const { config } = useAppConfig(); + const { displayName: authDisplayName, isAnonymous } = useAuth(); + const profilePictureUrl = useProfilePictureUrl(); + const [accountUsername, setAccountUsername] = useState(null); + + useEffect(() => { + if (!config?.enableLogin) { + setAccountUsername(null); + return; + } + if (authDisplayName) { + // The auth context has a name; don't bother hitting the REST + // endpoint, but clear any stale cached value from a prior call. + setAccountUsername(null); + return; + } + accountService + .getAccountData() + .then((data) => { + // Always reflect the latest result - including clearing it on + // sign-out, when the endpoint returns no username (or 401s into + // the catch branch below). Without this, signing out would leave + // the old username on screen. + setAccountUsername(data?.username ?? null); + }) + .catch(() => { + setAccountUsername(null); + }); + }, [config?.enableLogin, authDisplayName]); + + return { + displayName: + authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"), + profilePictureUrl, + isAnonymous, + }; +} diff --git a/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts b/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts new file mode 100644 index 0000000000..52702a3f74 --- /dev/null +++ b/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts @@ -0,0 +1,12 @@ +import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow"; + +/** + * Free credits left on this team's allowance, for the sidebar footer meter. + * Null hides the meter entirely. + * + * Core has no wallet — self-hosted installs aren't metered — so there is + * nothing to show. Cloud builds override this with the live wallet figure. + */ +export function useFreeCreditsSummary(): NavFooterCredits | null { + return null; +} diff --git a/frontend/editor/src/core/hooks/useOpenPlan.ts b/frontend/editor/src/core/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..da6fe2207e --- /dev/null +++ b/frontend/editor/src/core/hooks/useOpenPlan.ts @@ -0,0 +1,10 @@ +/** + * Opens the plan surface behind the sidebar footer's free-credits row, or null + * when this build has none (the row is then inert text rather than a button). + * + * Core ships no wallet and no plan section, so there is nothing to open. Builds + * that meter usage override this with their own surface. + */ +export function useOpenPlan(): (() => void) | null { + return null; +} diff --git a/frontend/editor/src/core/hooks/useOtherAppSwitch.ts b/frontend/editor/src/core/hooks/useOtherAppSwitch.ts new file mode 100644 index 0000000000..612589899b --- /dev/null +++ b/frontend/editor/src/core/hooks/useOtherAppSwitch.ts @@ -0,0 +1,12 @@ +import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter"; + +/** + * The sibling app this build can switch to (editor ⇄ processor), or null when + * there is none. The single gate behind both the brand switcher and the + * sidebar footer's "Open ..." row, so the two can never disagree about access. + * + * Core ships no processor, so there is nothing to switch to. + */ +export function useOtherAppSwitch(): NavFooterAppLink | null { + return null; +} diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index a7a68ea256..5354b56b63 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -6,5 +6,8 @@ export const qk = { ["editor", "endpointEnabled", endpoint] as const, footerInfo: () => ["editor", "footerInfo"] as const, groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const, + /** Keyed on the asking identity: two users must never share one answer. */ + portalAccess: (userId: string | null) => + ["editor", "portalAccess", userId] as const, users: () => ["editor", "users"] as const, } as const; diff --git a/frontend/editor/src/core/services/navFooterCache.ts b/frontend/editor/src/core/services/navFooterCache.ts new file mode 100644 index 0000000000..cbde941c81 --- /dev/null +++ b/frontend/editor/src/core/services/navFooterCache.ts @@ -0,0 +1,73 @@ +/** + * Last-known sidebar-footer state, so the rows are correct at first paint + * instead of arriving a request later. + * + * The footer is mounted by both apps, and the editor and processor are separate + * React trees with separate query caches — so without this, every navigation + * between them (and every remount inside them) re-ran the fetches and the rows + * visibly popped in and shoved each other around. Persisting to storage rather + * than to an in-memory cache is what makes it survive that boundary, and a + * reload. + * + * Deliberately stale-then-revalidate: what's stored is only ever what the + * backend last said, every reader refetches immediately and overwrites, and + * nothing is gated on it — the processor enforces its own access server-side, + * and a stale credit figure is replaced within a second of the wallet landing. + */ +const CREDITS_KEY = "stirling.navFooter.credits"; +const OTHER_APP_KEY = "stirling.navFooter.otherApp"; + +/** Figures, or null for a team that sees no meter at all (a paying one). */ +export type CachedCredits = { remaining: number; total: number } | null; + +function read(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + // Private mode / storage disabled — behave as a first-ever load. + return null; + } +} + +function write(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // Nothing to do: the cache is an optimisation, never a correctness input. + } +} + +/** `undefined` when this browser has never seen an answer. */ +export function readCachedCredits(): CachedCredits | undefined { + const raw = read(CREDITS_KEY); + if (raw === null) return undefined; + if (raw === "none") return null; + try { + const parsed: unknown = JSON.parse(raw); + if ( + typeof parsed === "object" && + parsed !== null && + typeof (parsed as CachedCredits & object).remaining === "number" && + typeof (parsed as CachedCredits & object).total === "number" + ) { + return parsed as CachedCredits; + } + } catch { + // Corrupt entry — fall through and treat it as never-seen. + } + return undefined; +} + +export function writeCachedCredits(credits: CachedCredits): void { + write(CREDITS_KEY, credits === null ? "none" : JSON.stringify(credits)); +} + +/** `undefined` when this browser has never seen an answer. */ +export function readCachedOtherApp(): boolean | undefined { + const raw = read(OTHER_APP_KEY); + return raw === null ? undefined : raw === "true"; +} + +export function writeCachedOtherApp(canOpen: boolean): void { + write(OTHER_APP_KEY, String(canOpen)); +} diff --git a/frontend/editor/src/core/ui/Avatar.css b/frontend/editor/src/core/ui/Avatar.css index e2e8dc63bd..360c15c566 100644 --- a/frontend/editor/src/core/ui/Avatar.css +++ b/frontend/editor/src/core/ui/Avatar.css @@ -46,6 +46,12 @@ height: 2.5rem; font-size: 1rem; } +/* Account-settings hero disc. */ +.sui-avatar--xl { + width: 4.5rem; + height: 4.5rem; + font-size: 1.75rem; +} .sui-avatar__img { width: 100%; diff --git a/frontend/editor/src/core/ui/Avatar.tsx b/frontend/editor/src/core/ui/Avatar.tsx index c7cfac501b..42e7aca11f 100644 --- a/frontend/editor/src/core/ui/Avatar.tsx +++ b/frontend/editor/src/core/ui/Avatar.tsx @@ -1,6 +1,7 @@ +import { useEffect, useState } from "react"; import "@app/ui/Avatar.css"; -export type AvatarSize = "xs" | "sm" | "md" | "lg"; +export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; export type AvatarTone = | "blue" | "purple" @@ -23,10 +24,12 @@ export interface AvatarProps { className?: string; } -function initialsOf(name: string): string { +function avatarInitials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "?"; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + // Single word (a username or an email) reads as one letter — two letters of + // "admin" ("AD") looks like a different person's initials, not a truncation. + if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } @@ -43,6 +46,13 @@ export function Avatar({ ariaLabel, className, }: AvatarProps) { + // A picture URL that 404s (expired signed URL, deleted upload) must not leave + // an empty disc — fall back to the same initials the no-picture case shows, so + // every surface rendering this identity agrees on what it draws. + const [srcFailed, setSrcFailed] = useState(false); + useEffect(() => setSrcFailed(false), [src]); + const showImage = Boolean(src) && !srcFailed; + const classes = [ "sui-avatar", `sui-avatar--${size}`, @@ -53,11 +63,16 @@ export function Avatar({ .filter(Boolean) .join(" "); - const content = src ? ( - {ariaLabel + const content = showImage ? ( + {ariaLabel setSrcFailed(true)} + /> ) : ( - {initialsOf(name)} + {avatarInitials(name)} ); diff --git a/frontend/editor/src/portal-saas/hooks/useFreeCreditsSummary.ts b/frontend/editor/src/portal-saas/hooks/useFreeCreditsSummary.ts new file mode 100644 index 0000000000..8f7a16ca90 --- /dev/null +++ b/frontend/editor/src/portal-saas/hooks/useFreeCreditsSummary.ts @@ -0,0 +1,7 @@ +/** + * SaaS has no link concept — the signed-in account IS the SaaS account, and the + * editor's cloud wallet hook is already in this build's {@code @app/*} cascade. + * Delegating to it means the processor footer and the editor footer share one + * wallet fetch and can't disagree, so there is nothing portal-specific to do. + */ +export { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary"; diff --git a/frontend/editor/src/portal-saas/hooks/useOpenPlan.ts b/frontend/editor/src/portal-saas/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..ce12e843b3 --- /dev/null +++ b/frontend/editor/src/portal-saas/hooks/useOpenPlan.ts @@ -0,0 +1,11 @@ +import { useCallback } from "react"; +import { useUI } from "@portal/contexts/UIContext"; + +/** + * SaaS processor: the settings modal it hosts carries the same Plan section the + * editor opens, so the footer's credits row lands both apps in one place. + */ +export function useOpenPlan(): (() => void) | null { + const { openSettings } = useUI(); + return useCallback(() => openSettings("plan"), [openSettings]); +} diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index dfb533f8ab..48b54b177d 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -123,8 +123,6 @@ } .portal-sidebar[data-collapsed] .portal-sidebar__footer { margin-inline: 0.375rem; - padding-inline: 0; - align-items: center; } .portal-sidebar__logo { @@ -179,10 +177,8 @@ gap: 0.125rem; } +/* The shared brings its own boxes, padding and gap; the sidebar + only positions it. */ .portal-sidebar__footer { margin: 0 0.625rem 0.75rem; - padding: 0.5rem 0.375rem; - display: flex; - flex-direction: column; - gap: 0.5rem; } diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index ebf91c636e..8ce7008d67 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -2,6 +2,10 @@ import { useMediaQuery } from "@mantine/hooks"; import { Tooltip } from "@mantine/core"; import { ActionIcon, NavItem, NavSurface } from "@app/ui"; import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; +import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; +import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; +import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary"; +import { useOpenPlan } from "@portal/hooks/useOpenPlan"; import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -10,7 +14,7 @@ import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; -import { CloseIcon, SettingsIcon } from "@portal/components/icons"; +import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, GROUP_PLATFORM, @@ -41,6 +45,9 @@ export function Sidebar() { const isMobile = useMediaQuery(MOBILE_QUERY, false, { getInitialValueInEffect: false, }); + const { displayName, profilePictureUrl } = useAccountIdentity(); + const credits = useFreeCreditsSummary(); + const openPlan = useOpenPlan(); // Collapse is a desktop-only affordance: on mobile the sidebar is an // off-canvas drawer, so the icon-rail state never applies there. @@ -146,15 +153,17 @@ export function Sidebar() { ))} - - - } - onClick={() => openSettings()} - /> - + } + collapsed={collapsed} + /> ); } diff --git a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx index c7ab0e8704..6ea4d8d336 100644 --- a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx +++ b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { Button, Card } from "@app/ui"; -import { formatPeriodDate, MeterBar, meterState } from "@app/billing"; +import { formatPeriodDate, MeterBar, remainingMeter } from "@app/billing"; import type { Wallet } from "@portal/api/billing"; /** @@ -10,8 +10,8 @@ import type { Wallet } from "@portal/api/billing"; * - No bundle → a slim "Get 12 months for the price of 10" offer nudge with a * "Review offer" CTA (the demo's commit-nudge card), shown only when a buyer * ({@code onBuy}, leader) is present. - * - Bundle held → the capacity meter (fills as the pool is drawn down, so it - * warns as capacity runs low) plus a "Top up" action for the leader. + * - Bundle held → the capacity meter (drains towards empty as the pool is drawn + * down, so it warns as capacity runs low) plus a "Top up" action for the leader. * * Prepaid is consumed before metered billing and sits outside the spend limit, so * it reads as its own dimension. Buying/topping up opens {@code BundleCheckoutModal} @@ -55,8 +55,7 @@ export function PrepaidCapacityCard({ const remaining = wallet.prepaidUnitsRemaining; const total = wallet.prepaidUnitsTotal; - const used = Math.max(0, total - remaining); - const { state, pct } = meterState(used, total); + const { state, pct } = remainingMeter(remaining, total); const stateLabel = state === "DEGRADED" ? t("portal.billing.prepaid.state.exhausted", "Used up") diff --git a/frontend/editor/src/portal/components/billing/WalletMeter.tsx b/frontend/editor/src/portal/components/billing/WalletMeter.tsx index c8be188390..9558960e89 100644 --- a/frontend/editor/src/portal/components/billing/WalletMeter.tsx +++ b/frontend/editor/src/portal/components/billing/WalletMeter.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Card } from "@app/ui"; -import { formatMinor, MeterBar, meterState } from "@app/billing"; +import { formatMinor, MeterBar, remainingMeter } from "@app/billing"; import type { Wallet } from "@portal/api/billing"; import type { LocalUsage } from "@portal/api/link"; @@ -15,8 +15,10 @@ interface Props { } /** - * The free Processor-trial meter — "X / N free PDFs used" against the one-time - * grant. Uses the shared {@link MeterBar} (same `paygf-meter` structure as the + * The free Processor-trial meter — "X of N free PDFs left" against the one-time + * grant, with what has been used alongside as the status badge. The bar shows what + * is left, so it drains towards empty as the grant is spent. + * Uses the shared {@link MeterBar} (same `paygf-meter` structure as the * cloud plan page). The subscribed spend-vs-cap meter is a separate surface * ({@code SpendLimitCard}); this card is only the free face. * @@ -30,7 +32,7 @@ export function WalletMeter({ wallet, unsynced, action }: Props) { const pending = unsynced?.totalUnsyncedUnits ?? 0; const used = wallet.billableUsed + pending; const remaining = Math.max(0, wallet.freeRemaining - pending); - const { state, pct } = meterState(used, wallet.freeAllowance); + const { state, pct } = remainingMeter(remaining, wallet.freeAllowance); const rate = wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0 ? wallet.pricePerDocMinor @@ -76,11 +78,14 @@ export function WalletMeter({ wallet, unsynced, action }: Props) { diff --git a/frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx new file mode 100644 index 0000000000..fb48250fed --- /dev/null +++ b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx @@ -0,0 +1,110 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary"; + +const fetchWallet = vi.fn(); +vi.mock("@portal/api/billing", () => ({ + fetchWallet: () => fetchWallet(), +})); + +function Probe() { + const credits = useFreeCreditsSummary(); + return ( + + {credits ? `${credits.remaining}/${credits.total}` : "none"} + + ); +} + +function renderFor(initialState: LinkState) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + , + ).getByTestId("credits"); +} + +describe("useFreeCreditsSummary (self-hosted) — wallet behind the link gate", () => { + beforeEach(() => { + // The figures persist across mounts now, so isolate the suite from itself. + localStorage.clear(); + fetchWallet.mockReset(); + fetchWallet.mockResolvedValue({ + status: "free", + freeRemaining: 247, + freeAllowance: 500, + }); + }); + + it("unlinked reads no wallet at all", async () => { + const el = renderFor("unlinked"); + await waitFor(() => expect(el.textContent).toBe("none")); + expect(fetchWallet).not.toHaveBeenCalled(); + }); + + it("linked surfaces the free grant", async () => { + const el = renderFor("linked-free"); + await waitFor(() => expect(el.textContent).toBe("247/500")); + }); + + it("hides the meter once the team subscribes", async () => { + // The grant is a lifetime pool that survives subscribing, so a paying team + // would otherwise sit on a spent meter forever. + fetchWallet.mockResolvedValue({ + status: "subscribed", + freeRemaining: 0, + freeAllowance: 500, + }); + const el = renderFor("linked-subscribed"); + // The row holds its space while the wallet loads, then drops once the + // answer says this team is paying. + await waitFor(() => expect(el.textContent).toBe("none")); + }); + + it("hides the meter when the wallet read fails", async () => { + fetchWallet.mockRejectedValue(new Error("saas unreachable")); + const el = renderFor("linked-subscribed"); + await waitFor(() => expect(el.textContent).toBe("none")); + }); + + it("ignores cached figures once the instance is unlinked", async () => { + // The cache survives an unlink and nothing rewrites it afterwards, so the + // linkage gate has to cover the seed too, not just the fetch. + const linked = renderFor("linked-free"); + await waitFor(() => expect(linked.textContent).toBe("247/500")); + cleanup(); + + fetchWallet.mockClear(); + const unlinked = renderFor("unlinked"); + expect(unlinked.textContent).toBe("none"); + expect(fetchWallet).not.toHaveBeenCalled(); + }); + + it("shows the last known figures while the wallet reloads", async () => { + // What stops the row popping in — and resizing the footer — every time the + // processor mounts. + const el = renderFor("linked-free"); + await waitFor(() => expect(el.textContent).toBe("247/500")); + cleanup(); + + let release: (v: unknown) => void = () => {}; + fetchWallet.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const second = renderFor("linked-free"); + // Seeded before the refetch lands... + expect(second.textContent).toBe("247/500"); + release({ status: "free", freeRemaining: 12, freeAllowance: 500 }); + // ...then updated in place, without the row ever being absent. + await waitFor(() => expect(second.textContent).toBe("12/500")); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts new file mode 100644 index 0000000000..3fd814ebb1 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useLink } from "@portal/contexts/LinkContext"; +import { fetchWallet } from "@portal/api/billing"; +import { qk } from "@portal/queries/keys"; +import { + readCachedCredits, + writeCachedCredits, + type CachedCredits, +} from "@app/services/navFooterCache"; +import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow"; + +/** + * Free credits left on this team's allowance, for the processor's sidebar + * footer meter. Null hides the meter. + * + * This is the portal's own seam rather than the editor's {@code + * @app/hooks/useFreeCreditsSummary}, because self-hosted resolves {@code @app/*} + * as proprietary → core: the cloud wallet hook isn't in that cascade, and the + * implementation can't move down into proprietary either, since core/desktop + * builds ship no portal and must never resolve {@code @portal}. Keeping it here + * means only builds that actually have a processor pull in the wallet read. + * + * Self-hosted reads the same {@code GET /api/v1/payg/wallet} the Usage page's + * trial meter renders — {@code apiClient.saas} with the admin's Supabase JWT, + * since the wallet lives in the cloud even when the instance doesn't. Gated on + * linkage: an unlinked instance has no wallet to read. + * + * Free teams only, matching the editor and the Plan page. The grant is a + * lifetime pool that survives subscribing, so a paying team would otherwise sit + * on a permanent "0 of 500" in red; their usage lives on Usage & Billing. + */ +export function useFreeCreditsSummary(): NavFooterCredits | null { + const { isLinked } = useLink(); + // Shared query key, so the footer rides the same cached snapshot as any other + // wallet reader rather than adding a fetch per mount. + const { data: wallet } = useQuery({ + queryKey: qk.wallet(isLinked), + queryFn: fetchWallet, + enabled: isLinked, + }); + // Shared with the editor's seam, so crossing between the two apps shows the + // figures the other one last saw rather than re-fetching into an empty row. + const [seed] = useState(readCachedCredits); + + const live: CachedCredits | undefined = !wallet + ? undefined + : wallet.status === "subscribed" + ? null + : { remaining: wallet.freeRemaining, total: wallet.freeAllowance }; + + useEffect(() => { + // Only once linked: an unlinked instance never asks, so it has no answer of + // its own and must not overwrite what the editor recorded. + if (isLinked && live !== undefined) writeCachedCredits(live); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wallet, isLinked]); + + // Linkage gates the seed as well as the fetch. The cache outlives an unlink + // — nothing refetches or rewrites it once the instance stops asking — so + // without this an unlinked instance would keep showing the figures from when + // it was linked, indefinitely. + if (!isLinked) return null; + return (live !== undefined ? live : seed) ?? null; +} diff --git a/frontend/editor/src/portal/hooks/useOpenPlan.ts b/frontend/editor/src/portal/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..57b30c91d0 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useOpenPlan.ts @@ -0,0 +1,13 @@ +import { useCallback } from "react"; +import { useView } from "@portal/contexts/ViewContext"; + +/** + * Self-hosted processor: settings carries no Plan section (it is a cloud + * surface, and this build's registry has none), so the footer's credits row + * opens the portal's own Usage & Billing view instead — the same figures, on + * the surface this flavor actually owns. + */ +export function useOpenPlan(): (() => void) | null { + const { setActiveView } = useView(); + return useCallback(() => setActiveView("usage"), [setActiveView]); +} diff --git a/frontend/editor/src/portal/queries/keys.ts b/frontend/editor/src/portal/queries/keys.ts index 6c29430cb4..e1c46a59c0 100644 --- a/frontend/editor/src/portal/queries/keys.ts +++ b/frontend/editor/src/portal/queries/keys.ts @@ -20,6 +20,8 @@ export const qk = { // Keyed on linkage: an unlinked account has no deal to read, so linking must not // serve the unlinked (null) snapshot back from cache. procurement: (linked: boolean) => ["portal", "procurement", linked] as const, + // Same reasoning: an unlinked instance has no wallet in the cloud. + wallet: (linked: boolean) => ["portal", "wallet", linked] as const, // Tier-dependent documents: (tier: Tier) => ["portal", "documents", tier] as const, diff --git a/frontend/editor/src/proprietary/billing/format.ts b/frontend/editor/src/proprietary/billing/format.ts index f91f98af44..ff44e69882 100644 --- a/frontend/editor/src/proprietary/billing/format.ts +++ b/frontend/editor/src/proprietary/billing/format.ts @@ -293,6 +293,29 @@ export function computeBundleQuote( export type MeterState = "FULL" | "WARNED" | "DEGRADED"; +/** + * Meter for a balance that is spent DOWN — a free grant, a prepaid pool. The + * bar shows what is LEFT, so full reads as "plenty" and empty as "none", which + * is how the sidebar footer's credits row reads and the only direction that + * matches a figure quoting the remainder. + * + * The state bands still key on consumption, so the tone is unchanged: amber + * once 80% is gone, red once it's exhausted. Meters for money SPENT against a + * cap keep using {@link meterState} directly — there a full bar correctly means + * "at your ceiling". + */ +export function remainingMeter( + remaining: number, + total: number, +): { state: MeterState; pct: number } { + const { state } = meterState(Math.max(0, total - remaining), total); + const pct = + total > 0 + ? Math.min(100, Math.max(0, (Math.max(0, remaining) / total) * 100)) + : 0; + return { state, pct }; +} + /** Warn (≥80%) / degrade (≥100%) band for a usage meter; mirrors the BE thresholds. */ export function meterState( used: number, diff --git a/frontend/editor/src/proprietary/billing/index.ts b/frontend/editor/src/proprietary/billing/index.ts index 687adec541..2fe9dffd93 100644 --- a/frontend/editor/src/proprietary/billing/index.ts +++ b/frontend/editor/src/proprietary/billing/index.ts @@ -14,6 +14,7 @@ export { docCapForMoney, formatPeriodDate, meterState, + remainingMeter, PREPAID_MONTHS_GRANTED, PREPAID_MONTHS_PAID, PDFS_PER_USER_MONTH, diff --git a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx index 2e02db3068..9ba0b6438d 100644 --- a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx +++ b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx @@ -1,15 +1,21 @@ -import { useNavigate } from "react-router-dom"; -import { useAuth } from "@app/auth/context"; import { Logo } from "@app/ui/Logo"; import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; -import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; +/** + * Sidebar brand header for builds that ship the processor. When this user can + * open it, the Stirling logo doubles as the editor⇄processor switcher: the mark + * morphs into a chevron and opens the switch menu (the same BrandSwitcher the + * processor sidebar uses). Users without access get a plain logo. + * + * The access gate lives in {@link useOtherAppSwitch} so this header and the + * sidebar footer's "Open PDF Processor" row are driven by one answer. + */ export function AppSwitcher({ collapsed }: AppSwitcherProps) { - const { portalAccess } = useAuth(); - const navigate = useNavigate(); + const otherApp = useOtherAppSwitch(); - if (!portalAccess) { + if (!otherApp) { return ( navigate(PORTAL_BASENAME)} + onSwitch={otherApp.onOpen} collapsed={collapsed} /> ); diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts new file mode 100644 index 0000000000..8bf07b5c2f --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts @@ -0,0 +1,15 @@ +import { useNavigate } from "react-router-dom"; +import { useAuth } from "@app/auth/context"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter"; + +/** + * Self-hosted: the Spring session carries `portalAccess`, so the switch to the + * processor is offered exactly when that flag is set. + */ +export function useOtherAppSwitch(): NavFooterAppLink | null { + const { portalAccess } = useAuth(); + const navigate = useNavigate(); + if (!portalAccess) return null; + return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) }; +} diff --git a/frontend/editor/src/saas/components/shared/AppSwitcher.tsx b/frontend/editor/src/saas/components/shared/AppSwitcher.tsx deleted file mode 100644 index 364f094478..0000000000 --- a/frontend/editor/src/saas/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useNavigate } from "react-router-dom"; -import { Logo } from "@app/ui/Logo"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; -import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; -import { usePortalAccess } from "@app/hooks/usePortalAccess"; -import { PORTAL_BASENAME } from "@app/routes/portalBasename"; - -/** - * SaaS sidebar brand header. When the backend says this user can open the - * processor (`/api/v1/auth/me` → `portalAccess` — the exact signal the - * processor's own gate uses), the Stirling logo doubles as the - * editor⇄processor switcher: the mark morphs into a chevron and opens the - * switch menu (same BrandSwitcher the processor sidebar uses). Users without - * access get a plain logo. - * - * Deliberately NOT gated on the editor's Supabase auth context: that context - * never fetches /me, so it can't know about portal access (and its session - * state doesn't always mirror the backend login that actually grants it). - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - const portalAccess = usePortalAccess(); - const navigate = useNavigate(); - - if (!portalAccess) { - return ( - - ); - } - - return ( - navigate(PORTAL_BASENAME)} - collapsed={collapsed} - /> - ); -} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx index fac87148fe..3a28152425 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx @@ -1,7 +1,6 @@ import React, { useState } from "react"; import { Alert, - Avatar, Divider, Group, Image, @@ -11,10 +10,12 @@ import { TextInput, Modal, } from "@mantine/core"; +import { Avatar } from "@app/ui/Avatar"; import { Button as DSButton } from "@app/ui/Button"; import { FilePicker } from "@app/ui/FilePicker"; import { useTranslation } from "react-i18next"; import { useAuth } from "@app/auth/UseSession"; +import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; import { isUserAnonymous, linkEmailIdentity, @@ -46,6 +47,8 @@ const Overview: React.FC = ({ onLogoutClick }) => { refreshProfilePicture, refreshProfilePictureMetadata, } = useAuth(); + // Same name + initials the sidebar footer draws, so the two discs agree. + const { displayName } = useAccountIdentity(); const PROFILE_BUCKET = "profile-pictures"; @@ -67,7 +70,6 @@ const Overview: React.FC = ({ onLogoutClick }) => { const provider = profilePictureMetadata?.provider; const profilePath = user ? `${user.id}/avatar` : null; - const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || "U"; const handleProfileUpload = async (file: File | null) => { if (!file || !user || !profilePath) { @@ -410,12 +412,9 @@ const Overview: React.FC = ({ onLogoutClick }) => { - {profileInitial} - + name={displayName} + size="xl" + />
= ({ onLogoutClick }) => { - {profileInitial} - + name={displayName} + size="xl" + />
navigate(PORTAL_BASENAME) }; +} diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx index a0e8ba618d..809138850a 100644 --- a/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx +++ b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { renderHook as baseRenderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; const get = vi.fn(); let currentUserId: string | null = null; @@ -20,10 +22,28 @@ function meReturning(portalAccess: boolean) { return { data: { user: { portalAccess } } }; } +// A fresh client per render, so one test's cached answer can't satisfy the +// next — each case exercises a cold cache unless it deliberately shares one. +let client: QueryClient; + +function renderHook(cb: () => T) { + return baseRenderHook(cb, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + describe("usePortalAccess", () => { beforeEach(() => { + // The hook now remembers the last answer across mounts, so without this a + // prior test's result seeds the next one. + localStorage.clear(); get.mockReset(); currentUserId = null; + client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, + }); }); it("reports the backend's answer for the signed-in user", async () => { @@ -82,12 +102,31 @@ describe("usePortalAccess", () => { expect(first.result.current).toBe(false); first.unmount(); - // The failure isn't sticky. + // The failure isn't sticky — a cold cache asks again. + client.clear(); get.mockResolvedValue(meReturning(true)); const second = renderHook(() => usePortalAccess()); await waitFor(() => expect(second.result.current).toBe(true)); }); + it("shows the last known answer at first paint, then revalidates", async () => { + // What stops the switcher and the footer's "Open ..." row popping in a + // request late on every mount. + currentUserId = "admin-1"; + get.mockResolvedValue(meReturning(true)); + const first = renderHook(() => usePortalAccess()); + await waitFor(() => expect(first.result.current).toBe(true)); + first.unmount(); + + client.clear(); + get.mockResolvedValue(meReturning(false)); + const second = renderHook(() => usePortalAccess()); + // Seeded from the remembered answer before the request lands... + expect(second.result.current).toBe(true); + // ...and corrected once the backend disagrees. + await waitFor(() => expect(second.result.current).toBe(false)); + }); + it("ignores a response that lands after unmount", async () => { currentUserId = "admin-1"; let resolveMe: (v: unknown) => void = () => {}; diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.ts b/frontend/editor/src/saas/hooks/usePortalAccess.ts index 442061cbe1..6e91f0864c 100644 --- a/frontend/editor/src/saas/hooks/usePortalAccess.ts +++ b/frontend/editor/src/saas/hooks/usePortalAccess.ts @@ -1,52 +1,64 @@ import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import apiClient from "@app/services/apiClient"; import { useAuth } from "@app/auth/UseSession"; +import { + readCachedOtherApp, + writeCachedOtherApp, +} from "@app/services/navFooterCache"; +import { qk } from "@app/query/keys"; + +async function fetchPortalAccess(): Promise { + const res = await apiClient.get<{ user?: { portalAccess?: boolean } }>( + "/api/v1/auth/me", + ); + return res.data.user?.portalAccess === true; +} /** * Whether the current user can open the processor (admin portal), straight * from the backend (`/api/v1/auth/me` → `portalAccess`) — the same signal the * processor's own SaasPortalGate uses. Components that must mirror processor - * access (e.g. the sidebar's editor⇄processor switcher) ask here. + * access (the sidebar's editor⇄processor switcher and its footer row) ask here. * * The editor's Supabase auth context can't *answer* this — it never fetches - * /me — so it is used only to identify who is asking. Keying the effect on - * that identity is what keeps the answer per-user: the SPA can swap users + * /me — so it is used only to identify who is asking. That identity is the + * cache key, which is what keeps the answer per-user: the SPA can swap users * without a reload (Supabase fires SIGNED_OUT/SIGNED_IN in place; only the - * settings Logout button hard-navigates), so any answer held beyond the - * current identity would leak to whoever signs in next. + * settings Logout button hard-navigates), and a keyed cache addresses each + * identity separately rather than holding one answer that would have to be + * invalidated on the swap — the bug class this hook once had. * - * Deliberately unmemoised beyond the mount: the one consumer (the sidebar - * switcher) mounts once, so a cross-mount cache would only add user-scoped - * state that has to be invalidated on identity change — the bug class this - * hook already had once. Guests skip the request entirely. + * Cached through the app query client, so leaving the editor for the processor + * and coming back resolves from cache: the switcher is there on first paint + * instead of appearing a request later. Guests skip the request entirely. */ export function usePortalAccess(): boolean { const { user } = useAuth(); const userId = user?.id ?? null; - const [access, setAccess] = useState(false); + // The query cache is per-tree and per-load, so it can't help a cold start or + // the hop into the processor, which mounts its own client. Seed from the last + // answer this browser saw so the switcher and the footer's "Open ..." row are + // there at first paint. Marked ancient so it still revalidates immediately. + const [seed] = useState(readCachedOtherApp); + + const { data, isSuccess } = useQuery({ + queryKey: qk.portalAccess(userId), + queryFn: fetchPortalAccess, + // Signed out: nothing to ask, and any previous answer is void. + enabled: userId !== null, + // Backend unreachable or guest (401) means no access now; a later refetch + // asks again rather than trusting the failure. + retry: false, + initialData: seed, + initialDataUpdatedAt: 0, + }); useEffect(() => { - // Signed out: nothing to ask, and any previous answer is void. - if (userId === null) { - setAccess(false); - return; - } + // Only a real answer is recorded — a failed probe is not one, so the next + // mount trusts the last backend response rather than a network blip. + if (isSuccess && data !== undefined) writeCachedOtherApp(data); + }, [isSuccess, data]); - let cancelled = false; - apiClient - .get<{ user?: { portalAccess?: boolean } }>("/api/v1/auth/me") - .then((res) => { - if (!cancelled) setAccess(res.data.user?.portalAccess === true); - }) - .catch(() => { - // Backend unreachable or guest (401): no access now; a remount or - // identity change asks again rather than trusting a failure. - if (!cancelled) setAccess(false); - }); - return () => { - cancelled = true; - }; - }, [userId]); - - return access; + return data === true; } diff --git a/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx b/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx new file mode 100644 index 0000000000..b72eac67f8 --- /dev/null +++ b/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { expectConsole } from "@app/tests/failOnConsole"; + +const get = vi.fn(); +vi.mock("@app/services/apiClient", () => ({ + default: { get: (...args: unknown[]) => get(...args) }, +})); +vi.mock("@app/hooks/walletDevPreview", () => ({ + getWalletDevPreview: () => null, +})); +vi.mock("@app/services/billing", () => ({ createPortalSession: vi.fn() })); +vi.mock("@app/platform/openExternal", () => ({ openExternal: vi.fn() })); + +const { useWallet } = await import("@app/hooks/useWallet"); + +/** Full enough for the hook's deep-compare, which reads every field. */ +function walletWith(freeRemaining: number) { + return { + data: { + teamId: 1, + status: "free", + role: "leader", + billingPeriodStart: "2026-08-01", + billingPeriodEnd: "2026-08-31", + billableUsed: 500 - freeRemaining, + billableLimit: 500, + freeAllowance: 500, + freeRemaining, + pricePerDocMinor: 2, + bundleRatePerCreditMinor: null, + currency: "usd", + estimatedBillMinor: 0, + capUsd: null, + noCap: false, + stripeSubscriptionId: null, + spendUnitsThisPeriod: 0, + docsProcessedThisPeriod: 0, + uniquePdfsThisPeriod: 0, + sizeMultiplierPdfsThisPeriod: 0, + billingMode: "metered", + prepaidUnitsRemaining: 0, + prepaidUnitsTotal: 0, + prepaidExpiresAt: null, + recent: [], + members: [], + categoryBreakdown: { api: 0, ai: 0, automation: 0 }, + categoryDocs: { api: 0, ai: 0, automation: 0 }, + }, + }; +} + +describe("useWallet — keeping the figures fresh", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + get.mockReset(); + get.mockResolvedValue(walletWith(500)); + }); + afterEach(() => vi.useRealTimers()); + + it("re-reads the wallet on the poll interval", async () => { + const { result } = renderHook(() => useWallet()); + await waitFor(() => expect(result.current.wallet).not.toBeNull()); + expect(get).toHaveBeenCalledTimes(1); + + get.mockResolvedValue(walletWith(480)); + await act(async () => { + vi.advanceTimersByTime(30_000); + }); + + await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(480)); + }); + + it("polls silently, so consumers gating on loading/error don't flicker", async () => { + const { result } = renderHook(() => useWallet()); + await waitFor(() => expect(result.current.wallet).not.toBeNull()); + + // A poll that fails must leave the last good snapshot, and must not raise + // `error` — Plan swaps a working page for an alert on that. + get.mockRejectedValue(new Error("network blip")); + await act(async () => { + vi.advanceTimersByTime(30_000); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.wallet?.freeRemaining).toBe(500); + }); + + it("settles loading when a silent poll supersedes an in-flight visible load", async () => { + // The mount load raises `loading`; a poll firing before it lands cancels it. + // If clearing the flag were the silent load's to skip, both would decline + // and `loading` would stay true forever — which permanently suppresses the + // limit modals, since they do `if (loading || !wallet) return null`. + const visibility = vi.spyOn(document, "visibilityState", "get"); + visibility.mockReturnValue("visible"); + + let landMount: (v: unknown) => void = () => {}; + get.mockReturnValueOnce( + new Promise((resolve) => { + landMount = resolve; + }), + ); + const { result } = renderHook(() => useWallet()); + expect(result.current.loading).toBe(true); + + get.mockResolvedValue(walletWith(470)); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + await act(async () => { + landMount(walletWith(500)); + }); + + await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(470)); + expect(result.current.loading).toBe(false); + visibility.mockRestore(); + }); + + it("clears a stale error once a silent poll succeeds", async () => { + // The visible mount load failing is meant to be logged; only the silent + // retries stay quiet. + expectConsole.warn(/\[useWallet\] fetch failed/); + get.mockRejectedValueOnce(new Error("network blip")); + const { result } = renderHook(() => useWallet()); + await waitFor(() => expect(result.current.error).not.toBeNull()); + + get.mockResolvedValue(walletWith(500)); + await act(async () => { + vi.advanceTimersByTime(30_000); + }); + + await waitFor(() => expect(result.current.error).toBeNull()); + expect(result.current.wallet?.freeRemaining).toBe(500); + }); + + it("stops polling while the tab is hidden and re-reads on return", async () => { + const visibility = vi.spyOn(document, "visibilityState", "get"); + visibility.mockReturnValue("visible"); + const { result } = renderHook(() => useWallet()); + await waitFor(() => expect(result.current.wallet).not.toBeNull()); + const afterMount = get.mock.calls.length; + + visibility.mockReturnValue("hidden"); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(120_000); + }); + expect(get).toHaveBeenCalledTimes(afterMount); + + visibility.mockReturnValue("visible"); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + await waitFor(() => expect(get.mock.calls.length).toBe(afterMount + 1)); + visibility.mockRestore(); + }); +});