mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Consolidate the editor + processor sidebar footers into one component (#7539)
## What Both sidebars ended in a different bottom section. The editor showed an account row (avatar, name, settings); the processor showed a "Link Stirling account" CTA plus a `Settings` nav item and no identity at all. They are now **one shared `<NavFooter>`** rendering the same rows in both apps, in this order: 1. the link-account CTA (self-hosted, when unlinked) 2. free credits remaining 3. **Open \<the other app\>** 4. the account row — avatar, name, settings It's a **single surface** with hairline dividers between rows, not stacked cards. Rows are assembled as a list, so a row this build doesn't show (no wallet, no processor access, nothing to link) takes its divider with it rather than leaving a stray line. This also fixes the profile-picture/initials desync between the sidebar and the account settings page. ## Screenshots Captured with the stubbed Playwright harness at 1600x900, scoped to the sidebar and auto-cropped to the region that actually changed. Base is `origin/main`; every state is driven by dummy backend stubs so all the nav-bar permutations are covered. <img width="2104" height="3044" alt="montage_cloud-dark" src="https://github.com/user-attachments/assets/667c9a71-3ab7-4582-9259-08cda521e238" /> <img width="2104" height="3044" alt="montage_cloud-light" src="https://github.com/user-attachments/assets/126bc994-efa3-436b-bf50-31d76f574eaa" /> <img width="2104" height="1492" alt="montage_editor-dark" src="https://github.com/user-attachments/assets/c725726d-ea90-4fa9-bf0a-6014f3729869" /> <img width="2104" height="1490" alt="montage_editor-light" src="https://github.com/user-attachments/assets/d0d34e0a-ac60-4f8c-af15-afb66fbd679e" /> <img width="2104" height="1066" alt="montage_processor-dark" src="https://github.com/user-attachments/assets/51ccfe63-c327-41f4-ab91-74555b6f7148" /> <img width="2104" height="1068" alt="montage_processor-light" src="https://github.com/user-attachments/assets/c90d7aba-90fa-4998-ac8f-26ce666dde71" /> The free-credits meter is a cloud-build surface, so the self-hosted capture can't reach it. Those states come from the new Storybook stories with dummy wallet data (`Shared/NavFooter`), which is also where the credit tone bands and the collapsed rail are easiest to review. ## How it's wired `NavFooter` is purely presentational. Each app resolves its own data through three `@app/*` seams, so core carries no build-specific gating and any box whose data is absent is dropped rather than rendered empty. | Seam | core | cloud / proprietary / saas | |---|---|---| | `useFreeCreditsSummary` | `null` — self-hosted editor installs aren't metered | cloud reads `freeRemaining` / `freeAllowance` off the same `useWallet()` the Plan page's free meter uses, so the sidebar and Plan can't disagree | | `useOtherAppSwitch` | `null` — core ships no processor | gated on `portalAccess` (`/api/v1/auth/me` in SaaS, the Spring session flag self-hosted) | | Link-account CTA | n/a | unchanged conditions — passed in as `accountExtras`, still only when `linkState === "unlinked"`, still a no-op in SaaS | - The processor reads the meter through its own `@portal/hooks/useFreeCreditsSummary` rather than the editor's `@app` one. Self-hosted resolves `@app/*` as proprietary → core, where the cloud wallet hook isn't in the cascade, and the implementation can't live in `proprietary/` because core/desktop builds ship no portal and must never resolve `@portal`. Keeping it in `portal/` gets the figure to the linked self-hosted processor without weakening that rule; it reads the same `GET /api/v1/payg/wallet` the Usage page's trial meter already renders, gated on link state and behind the portal's query cache. `portal-saas/` just re-exports the cloud hook, so both footers share one fetch. The processor-access gate previously lived in two near-identical `AppSwitcher` copies. It moves into `useOtherAppSwitch`, `AppSwitcher` now reads it too, and the duplicate `saas/components/shared/AppSwitcher.tsx` is deleted — the logo switcher and the footer row can no longer disagree about access. ## Profile picture sync One `useAccountIdentity` hook now backs the editor footer, the processor footer and the account settings page. Previously settings derived its initial from `email[0]` while the sidebar used `displayName[0]`, and the two drew different blue discs. Alongside that, the shared `Avatar`: - falls back to initials when a picture URL fails to load, instead of leaving an empty disc - renders one letter for single-word names (`admin` → "A", not "AD") - gains an `xl` size so the settings hero disc is the same component ## Notes - Labelled **"Free credits"** rather than "free monthly credits": `freeAllowance` is documented as a one-time lifetime grant, not a monthly reset, so "monthly" would misdescribe the data. Happy to change if the backend semantics differ from the type comments. ## Testing - `task frontend:check` and `task frontend:typecheck:all` pass (all 9 build variants). - 9 new `Shared/NavFooter` stories pass the Chromium + axe story scan; `frontend:storybook:a11y:changed` reports no regressions. - Stubbed E2E suite passes, including the `config-button` tour/settings specs that target the account row. Two failures (`console-clean › landing`, `viewer-text-selection › Ctrl+C`) also fail on `origin/main` locally — they need a backend on :8080 and clipboard permissions.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 }) {
|
||||
<MeterBar
|
||||
state={state}
|
||||
pct={pct}
|
||||
barLabel={t("payg.free.hero.barAria", "Free PDFs used")}
|
||||
figure={snap.billableUsed.toLocaleString()}
|
||||
capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
|
||||
barLabel={t("payg.free.hero.barAria", "Free PDFs remaining")}
|
||||
figure={remaining.toLocaleString()}
|
||||
capSuffix={t("payg.free.hero.capSuffix", "of {{limit}} free PDFs left", {
|
||||
limit: snap.billableLimit.toLocaleString(),
|
||||
})}
|
||||
statusLabel={stateLabel}
|
||||
@@ -175,15 +177,15 @@ export function prepaidSnapshotFromWallet(
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepaid capacity meter. The bar fills as the pool is drawn down ({@code used =
|
||||
* total − remaining}), so it WARNs when the pool is running low and DEGRADEs once
|
||||
* exhausted — same bands as the free/cap meters. Prepaid is consumed ahead of the
|
||||
* meter and outside the spend cap, so it reads as its own dimension.
|
||||
* Prepaid capacity meter. The bar shows what is left and drains towards empty,
|
||||
* while the bands still key on what is gone ({@code used = total − remaining}), so
|
||||
* it WARNs when the pool is running low and DEGRADEs once exhausted — same bands
|
||||
* as the free/cap meters. Prepaid is consumed ahead of the meter and outside the
|
||||
* spend cap, so it reads as its own dimension.
|
||||
*/
|
||||
export function PrepaidCapacityMeterPanel({ snap }: { snap: PrepaidSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
const used = Math.max(0, snap.total - snap.remaining);
|
||||
const { state, pct } = meterState(used, snap.total);
|
||||
const { state, pct } = remainingMeter(snap.remaining, snap.total);
|
||||
const stateLabel =
|
||||
state === "DEGRADED"
|
||||
? t("payg.prepaid.state.exhausted", "Used up")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useWallet } from "@app/hooks/useWallet";
|
||||
import {
|
||||
readCachedCredits,
|
||||
writeCachedCredits,
|
||||
type CachedCredits,
|
||||
} from "@app/services/navFooterCache";
|
||||
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
|
||||
|
||||
/** The wallet reduced to what the footer shows: figures, or null for a payer. */
|
||||
function toCredits(
|
||||
status: string,
|
||||
freeRemaining: number,
|
||||
freeAllowance: number,
|
||||
): CachedCredits {
|
||||
// Free teams only. The grant is a lifetime pool that survives subscribing, so
|
||||
// a paying team would otherwise sit on a permanent "0 of 500" in red while
|
||||
// nothing is wrong. Plan draws the same line — subscribed teams get the
|
||||
// spend-vs-cap meter there, and admins get usage in the processor.
|
||||
if (status === "subscribed") return null;
|
||||
return { remaining: freeRemaining, total: freeAllowance };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud builds read the free grant off the live wallet — the same snapshot the
|
||||
* Plan page's free meter renders, so the sidebar and Plan can't disagree.
|
||||
*
|
||||
* Seeded from the last figures this browser saw, so the row is right at first
|
||||
* paint and stays put while the wallet refetches underneath. Only a browser
|
||||
* that has never loaded a wallet has nothing to show, and that one time the row
|
||||
* animates in. The seed is read once, at first render: later reads would fight
|
||||
* the live value, and the whole point is that the row stops moving.
|
||||
*/
|
||||
export function useFreeCreditsSummary(): NavFooterCredits | null {
|
||||
const { wallet } = useWallet();
|
||||
const [seed] = useState(readCachedCredits);
|
||||
|
||||
const live = wallet
|
||||
? toCredits(wallet.status, wallet.freeRemaining, wallet.freeAllowance)
|
||||
: undefined;
|
||||
|
||||
// useWallet reuses the snapshot reference when nothing changed, so keying on
|
||||
// it writes only on a real change, not on every render.
|
||||
useEffect(() => {
|
||||
if (live !== undefined) writeCachedCredits(live);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wallet]);
|
||||
|
||||
return (live !== undefined ? live : seed) ?? null;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -32,6 +32,14 @@
|
||||
* promise see the UI flip exactly once the new state is visible — no
|
||||
* intermediate flash of the old value.
|
||||
*
|
||||
* <h2>Freshness</h2>
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* <h2>Dev preview fallback</h2>
|
||||
*
|
||||
* 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<Promise<void> | 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<Wallet>("/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<typeof setInterval> | 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <NavFooter>: 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;
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement, FileSidebarProps>(
|
||||
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<string | null>(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<StirlingFileStub[]>([]);
|
||||
@@ -1115,7 +1084,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
)}
|
||||
data-testid="open-files-page"
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
|
||||
<OpenInFullIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
@@ -1264,70 +1233,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
{/* Getting-started checklist, floating above the footer (SaaS only). */}
|
||||
<SidebarChecklistSlot collapsed={collapsed} />
|
||||
|
||||
{/* Box 3 — account footer (avatar + name + settings). */}
|
||||
<NavSurface className="file-sidebar-footer-box">
|
||||
{/* Bottom bar: user name + settings */}
|
||||
<Tooltip
|
||||
label={
|
||||
onOpenSettings
|
||||
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
|
||||
: displayName
|
||||
}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className="file-sidebar-bottom-bar"
|
||||
onClick={onOpenSettings}
|
||||
role={onOpenSettings ? "button" : undefined}
|
||||
tabIndex={onOpenSettings ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onOpenSettings
|
||||
? (e) => 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}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-bottom-avatar${
|
||||
showProfilePicture
|
||||
? " file-sidebar-bottom-avatar--picture"
|
||||
: ""
|
||||
}`}
|
||||
aria-label={displayName}
|
||||
>
|
||||
{showProfilePicture ? (
|
||||
<img
|
||||
src={profilePictureUrl}
|
||||
alt=""
|
||||
className="file-sidebar-bottom-avatar-img"
|
||||
onError={() => setPictureFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
displayName.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-bottom-name sidebar-content-fade">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{onOpenSettings && !collapsed && (
|
||||
<div className="file-sidebar-bottom-settings">
|
||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</NavSurface>
|
||||
{/* Box 3 — the shared footer: credits, app switch, account row. */}
|
||||
<NavFooter
|
||||
className="file-sidebar-footer-box"
|
||||
displayName={displayName}
|
||||
profilePictureUrl={profilePictureUrl}
|
||||
onOpenSettings={onOpenSettings}
|
||||
credits={credits}
|
||||
onOpenPlan={openPlan ?? undefined}
|
||||
otherApp={otherApp}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<typeof NavFooter> = {
|
||||
title: "Shared/NavFooter",
|
||||
component: NavFooter,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
displayName: "admin",
|
||||
onOpenSettings: () => {},
|
||||
credits: { remaining: 247, total: 500 },
|
||||
onOpenPlan: () => {},
|
||||
otherApp: { app: "processor", onOpen: () => {} },
|
||||
},
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div
|
||||
style={{
|
||||
width: "16.25rem",
|
||||
background: "var(--c-bg)",
|
||||
padding: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NavFooter>;
|
||||
|
||||
/** 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: (
|
||||
<NavItem
|
||||
id="account-link"
|
||||
label="Link Stirling account"
|
||||
icon={<LinkIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** 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: <RendersNothing /> },
|
||||
};
|
||||
|
||||
/** A real profile picture replaces the initials disc. */
|
||||
export const WithProfilePicture: Story = {
|
||||
args: {
|
||||
displayName: "Ada Lovelace",
|
||||
profilePictureUrl:
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="black"/><circle cx="32" cy="24" r="12" fill="white"/><ellipse cx="32" cy="56" rx="20" ry="16" fill="white"/></svg>',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** 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) => (
|
||||
<div
|
||||
style={{
|
||||
width: "3.5rem",
|
||||
background: "var(--c-bg)",
|
||||
padding: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -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 <MantineProvider>{ui}</MantineProvider>;
|
||||
}
|
||||
|
||||
function renderFooter() {
|
||||
const { container } = render(
|
||||
withProviders(
|
||||
<NavFooter
|
||||
displayName="admin"
|
||||
onOpenSettings={() => {}}
|
||||
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(
|
||||
<NavFooter
|
||||
displayName="admin"
|
||||
onOpenSettings={() => {}}
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 <the other app>"
|
||||
* 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: (
|
||||
<NavFooterCreditsRow
|
||||
credits={credits}
|
||||
collapsed={collapsed}
|
||||
label={t("navFooter.credits.label", "Free credits")}
|
||||
onOpen={onOpenPlan}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (otherApp) {
|
||||
rows.push({
|
||||
key: "switch",
|
||||
node: (
|
||||
<Tooltip
|
||||
label={openAppLabel(otherApp.app, t)}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-footer__row"
|
||||
onClick={() => otherApp.onOpen()}
|
||||
// Collapsed drops the visible label, so name the button here too.
|
||||
aria-label={openAppLabel(otherApp.app, t)}
|
||||
>
|
||||
<span className="nav-footer__row-icon" aria-hidden>
|
||||
<BrandMark height="1.125rem" />
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="nav-footer__row-label">
|
||||
{openAppLabel(otherApp.app, t)}
|
||||
</span>
|
||||
{/* "Takes you there", not "opens a new tab" — both apps are
|
||||
one SPA, so this navigates in place. */}
|
||||
<span className="nav-footer__trailing" aria-hidden>
|
||||
<ArrowForwardIcon sx={{ fontSize: "1rem" }} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
rows.push({
|
||||
key: "account",
|
||||
node: (
|
||||
<Tooltip
|
||||
label={accountLabel}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-footer__row nav-footer__account"
|
||||
// Called with no args: handlers that take optional params (the
|
||||
// processor's openSettings(section?)) must not receive the event.
|
||||
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
|
||||
disabled={!onOpenSettings}
|
||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||
aria-label={accountLabel}
|
||||
>
|
||||
{/* Decorative: the button's own label already names the account, so
|
||||
an alt/label here would just repeat it to a screen reader. */}
|
||||
<span aria-hidden>
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={displayName}
|
||||
src={profilePictureUrl ?? undefined}
|
||||
/>
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="nav-footer__row-label sidebar-content-fade">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{onOpenSettings && !collapsed && (
|
||||
<span className="nav-footer__trailing" aria-hidden>
|
||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<NavSurface
|
||||
className={["nav-footer", className ?? ""].filter(Boolean).join(" ")}
|
||||
data-collapsed={collapsed || undefined}
|
||||
data-animate={animate || undefined}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="nav-footer__slot">
|
||||
{row.node}
|
||||
</div>
|
||||
))}
|
||||
</NavSurface>
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Tooltip
|
||||
label={`${label}: ${count}`}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<Row onOpen={onOpen} label={`${label}: ${count}`}>
|
||||
{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.
|
||||
<CreditsRing
|
||||
fraction={total > 0 ? remaining / total : 0}
|
||||
tone={tone}
|
||||
label={`${label}: ${count}`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="nav-footer__credits-head">
|
||||
<span className="nav-footer__dot" data-tone={tone} aria-hidden />
|
||||
<span className="nav-footer__credits-label">{label}</span>
|
||||
<span className="nav-footer__credits-count">{count}</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={total > 0 ? remaining / total : 0}
|
||||
height={6}
|
||||
color={`var(--c-${tone})`}
|
||||
label={`${label}: ${count}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<svg
|
||||
className="nav-footer__credits-ring"
|
||||
viewBox="0 0 20 20"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
<circle
|
||||
className="nav-footer__credits-ring-track"
|
||||
cx="10"
|
||||
cy="10"
|
||||
r={RADIUS}
|
||||
/>
|
||||
<circle
|
||||
className="nav-footer__credits-ring-fill"
|
||||
cx="10"
|
||||
cy="10"
|
||||
r={RADIUS}
|
||||
stroke={`var(--c-${tone})`}
|
||||
strokeDasharray={`${filled} ${circumference - filled}`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <div className={className}>{children}</div>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => onOpen()}
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 ? (
|
||||
<img src={src} alt={ariaLabel ?? name} className="sui-avatar__img" />
|
||||
const content = showImage ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={ariaLabel ?? name}
|
||||
className="sui-avatar__img"
|
||||
onError={() => setSrcFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="sui-avatar__initials" aria-hidden>
|
||||
{initialsOf(name)}
|
||||
{avatarInitials(name)}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 <NavFooter> 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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<NavSurface className="portal-sidebar__footer">
|
||||
<LinkAccountFooterItem />
|
||||
<NavItem
|
||||
id="settings"
|
||||
label={t("portal.nav.settings")}
|
||||
icon={<SettingsIcon />}
|
||||
onClick={() => openSettings()}
|
||||
/>
|
||||
</NavSurface>
|
||||
<NavFooter
|
||||
className="portal-sidebar__footer"
|
||||
displayName={displayName}
|
||||
profilePictureUrl={profilePictureUrl}
|
||||
onOpenSettings={openSettings}
|
||||
credits={credits}
|
||||
onOpenPlan={openPlan ?? undefined}
|
||||
otherApp={{ app: "editor", onOpen: goToEditor }}
|
||||
accountExtras={<LinkAccountFooterItem />}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
<MeterBar
|
||||
state={state}
|
||||
pct={pct}
|
||||
barLabel={t("portal.billing.walletMeter.barAria", "Free PDFs used")}
|
||||
figure={used.toLocaleString()}
|
||||
barLabel={t(
|
||||
"portal.billing.walletMeter.barAria",
|
||||
"Free PDFs remaining",
|
||||
)}
|
||||
figure={remaining.toLocaleString()}
|
||||
capSuffix={t(
|
||||
"portal.billing.walletMeter.capSuffix",
|
||||
"of {{allowance}} free PDFs used",
|
||||
"of {{allowance}} free PDFs left",
|
||||
{
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
@@ -88,11 +93,8 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
|
||||
)}
|
||||
statusLabel={t(
|
||||
"portal.billing.walletMeter.statusLabel",
|
||||
"{{remaining}} left",
|
||||
{
|
||||
count: remaining,
|
||||
remaining: remaining.toLocaleString(),
|
||||
},
|
||||
"{{used}} used",
|
||||
{ count: used, used: used.toLocaleString() },
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<span data-testid="credits">
|
||||
{credits ? `${credits.remaining}/${credits.total}` : "none"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFor(initialState: LinkState) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<LinkProvider initialState={initialState}>
|
||||
<Probe />
|
||||
</LinkProvider>
|
||||
</QueryClientProvider>,
|
||||
).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"));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
docCapForMoney,
|
||||
formatPeriodDate,
|
||||
meterState,
|
||||
remainingMeter,
|
||||
PREPAID_MONTHS_GRANTED,
|
||||
PREPAID_MONTHS_PAID,
|
||||
PDFS_PER_USER_MONTH,
|
||||
|
||||
@@ -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 (
|
||||
<Logo
|
||||
variant={collapsed ? "iconOnly" : "iconAndText"}
|
||||
@@ -22,7 +28,7 @@ export function AppSwitcher({ collapsed }: AppSwitcherProps) {
|
||||
return (
|
||||
<BrandSwitcher
|
||||
current="editor"
|
||||
onSwitch={() => navigate(PORTAL_BASENAME)}
|
||||
onSwitch={otherApp.onOpen}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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 (
|
||||
<Logo
|
||||
variant={collapsed ? "iconOnly" : "iconAndText"}
|
||||
iconHeight="1.6rem"
|
||||
textHeight="1.3rem"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandSwitcher
|
||||
current="editor"
|
||||
onSwitch={() => navigate(PORTAL_BASENAME)}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<OverviewProps> = ({ 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<OverviewProps> = ({ 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<OverviewProps> = ({ onLogoutClick }) => {
|
||||
<Group align="center" gap="md">
|
||||
<Avatar
|
||||
src={profilePictureUrl || undefined}
|
||||
radius="xl"
|
||||
size={72}
|
||||
color="blue"
|
||||
>
|
||||
{profileInitial}
|
||||
</Avatar>
|
||||
name={displayName}
|
||||
size="xl"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -450,12 +449,9 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
<Group align="center" gap="md">
|
||||
<Avatar
|
||||
src={profilePictureUrl || undefined}
|
||||
radius="xl"
|
||||
size={72}
|
||||
color="blue"
|
||||
>
|
||||
{profileInitial}
|
||||
</Avatar>
|
||||
name={displayName}
|
||||
size="xl"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { usePortalAccess } from "@app/hooks/usePortalAccess";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
|
||||
|
||||
/**
|
||||
* SaaS: the editor's Supabase context never fetches /me, so processor access
|
||||
* comes from the backend via {@link usePortalAccess} — the same signal the
|
||||
* processor's own gate uses.
|
||||
*/
|
||||
export function useOtherAppSwitch(): NavFooterAppLink | null {
|
||||
const portalAccess = usePortalAccess();
|
||||
const navigate = useNavigate();
|
||||
if (!portalAccess) return null;
|
||||
return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) };
|
||||
}
|
||||
@@ -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<T>(cb: () => T) {
|
||||
return baseRenderHook(cb, {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
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 = () => {};
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user