merge main
This commit is contained in:
+4
-4
@@ -24,10 +24,10 @@
|
||||
/editor/.env.local
|
||||
/editor/.env.*.local
|
||||
|
||||
# Root .gitignore ignores all .env* - whitelist our committed ones here
|
||||
!.env
|
||||
!.env.desktop
|
||||
!.env.saas
|
||||
# Root .gitignore ignores all .env* - whitelist only our committed ones, anchored
|
||||
# to their app so a stray top-level frontend/.env stays ignored (Storybook's SaaS
|
||||
# mock env is injected via .storybook/main.ts, not a file).
|
||||
!/portal/.env
|
||||
!/editor/.env
|
||||
!/editor/.env.desktop
|
||||
!/editor/.env.saas
|
||||
|
||||
@@ -51,6 +51,16 @@ const config: StorybookConfig = {
|
||||
],
|
||||
}),
|
||||
);
|
||||
// Point apiClient.saas at a mock origin so the SaaS-backed billing stories
|
||||
// (SubscribedPlanView, PaymentMethodCard, InvoicesList) resolve a base URL and
|
||||
// their MSW handlers (which match "*/api/v1/payg/...") can intercept. The host
|
||||
// never receives a real request — MSW answers first. Injected here, next to the
|
||||
// MSW setup, rather than via a frontend/.env so no stray env file can leak into a
|
||||
// real portal/editor build (those load env from their own roots).
|
||||
config.define = {
|
||||
...(config.define ?? {}),
|
||||
"import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,10 +14,12 @@ import { MantineProvider } from "@mantine/core";
|
||||
void React;
|
||||
|
||||
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
|
||||
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
|
||||
import { ThemeProvider } from "@portal/contexts/ThemeContext";
|
||||
import { UIProvider } from "@portal/contexts/UIContext";
|
||||
import { mantineTheme } from "@portal/theme/mantineTheme";
|
||||
import { handlers } from "@portal/mocks/handlers";
|
||||
import { configureSupabase } from "@shared/auth/supabase/supabaseClient";
|
||||
|
||||
import "@mantine/core/styles.css";
|
||||
import "@shared/tokens/tokens.css";
|
||||
@@ -26,6 +28,27 @@ import "@shared/tokens/base.css";
|
||||
// Start MSW once. Storybook runs in a browser so this uses the service worker.
|
||||
initialize({ onUnhandledRequest: "bypass" }, handlers);
|
||||
|
||||
// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment
|
||||
// method, wallet) clear the session check and reach the MSW handlers instead of
|
||||
// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally
|
||||
// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only
|
||||
// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via
|
||||
// .storybook/main.ts's viteFinal define, not a frontend/.env file.
|
||||
const saasStub = configureSupabase({
|
||||
url: "http://saas.mock",
|
||||
key: "storybook-anon-key",
|
||||
authOptions: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
});
|
||||
saasStub.auth.getSession = async () =>
|
||||
({
|
||||
data: { session: { access_token: "storybook-fake-jwt" } },
|
||||
error: null,
|
||||
}) as Awaited<ReturnType<typeof saasStub.auth.getSession>>;
|
||||
|
||||
/**
|
||||
* Bridge between Storybook's `tier` global toolbar and the actual TierProvider.
|
||||
* Without this the toolbar would just change a label; with it, every story
|
||||
@@ -67,6 +90,8 @@ function ThemeWatcher() {
|
||||
|
||||
const withProviders: Decorator = (Story, context) => {
|
||||
const tier = (context.globals.tier as Tier) ?? "pro";
|
||||
const linkState =
|
||||
(context.globals.linkState as LinkState) ?? "linked-subscribed";
|
||||
// withThemeByDataAttribute exposes the toolbar theme as the `theme` global.
|
||||
// Bind Mantine's color scheme to it so Mantine chrome (inputs, focus rings,
|
||||
// default surfaces) follows the dark toggle alongside the SUI CSS variables.
|
||||
@@ -78,12 +103,16 @@ const withProviders: Decorator = (Story, context) => {
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<ThemeProvider>
|
||||
<MantineProvider theme={mantineTheme} forceColorScheme={colorScheme}>
|
||||
<TierKey tier={tier}>
|
||||
<UIProvider>
|
||||
<ThemeWatcher />
|
||||
<Story />
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
|
||||
from useLink() (matches App.tsx's nesting). */}
|
||||
<LinkProvider key={linkState} initialState={linkState}>
|
||||
<TierKey tier={tier}>
|
||||
<UIProvider>
|
||||
<ThemeWatcher />
|
||||
<Story />
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
</LinkProvider>
|
||||
</MantineProvider>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>
|
||||
@@ -128,6 +157,20 @@ const preview: Preview = {
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
linkState: {
|
||||
name: "Link",
|
||||
description: "Account-link state — drives useLink() everywhere",
|
||||
defaultValue: "linked-subscribed",
|
||||
toolbar: {
|
||||
icon: "link",
|
||||
items: [
|
||||
{ value: "unlinked", title: "Unlinked" },
|
||||
{ value: "linked-free", title: "Linked · Free" },
|
||||
{ value: "linked-subscribed", title: "Linked · PAYG" },
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
withProviders,
|
||||
|
||||
@@ -8003,6 +8003,8 @@ zoomIn = "Zoom In"
|
||||
zoomOut = "Zoom Out"
|
||||
|
||||
[viewer.attachments]
|
||||
addAttachment = "Add attachment"
|
||||
close = "Close attachments"
|
||||
empty = "No attachments in this document"
|
||||
loading = "Loading attachments..."
|
||||
noDocument = "Open a PDF to view its attachments."
|
||||
@@ -8016,6 +8018,7 @@ addComment = "Add comment"
|
||||
addCommentPlaceholder = "Add comment..."
|
||||
addLink = "Add link"
|
||||
addReplyPlaceholder = "Add reply..."
|
||||
close = "Close comments"
|
||||
deleteAnnotationAndComment = "Delete annotation & comment"
|
||||
deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything."
|
||||
deleteTitle = "Remove annotation from comments?"
|
||||
@@ -8026,6 +8029,7 @@ moreActions = "More actions"
|
||||
nComments_one = "{{count}} comment"
|
||||
nComments_other = "{{count}} comments"
|
||||
pageLabel = "Page {{page}}"
|
||||
placingHint = "Click a page to place… (cancel)"
|
||||
removeCommentOnly = "Remove comment only"
|
||||
saveReply = "Save reply"
|
||||
title = "Comments"
|
||||
|
||||
@@ -5964,12 +5964,40 @@ statDocsEnforced = "Docs enforced"
|
||||
statusActive = "Active"
|
||||
statusPaused = "Paused"
|
||||
|
||||
[policies.enforcement]
|
||||
applying = "Applying {{names}}"
|
||||
applyingProgress = "Applying {{names}} ({{done}} of {{total}})"
|
||||
exportFailureBody = "Security policies couldn't be applied. Files were exported as-is."
|
||||
exportFailureTitle = "Exported without enforcement"
|
||||
failureBody = "{{failures}} of {{total}} file(s) couldn't be processed and were exported as-is."
|
||||
failureTitle = "Exported without full enforcement"
|
||||
printPolicyAppliedBody = "This PDF was updated to meet a policy. Review the changes, then print again."
|
||||
printPolicyAppliedTitle = "Policy applied before printing"
|
||||
queued = "+{{count}} queued"
|
||||
successTitle = "{{names}} applied"
|
||||
summaryMore = "{{first}}, {{second}} and {{more}} more"
|
||||
summaryTwo = "{{first}} and {{second}}"
|
||||
|
||||
[policies.enforcement.triggerVerb]
|
||||
convert = "Enforcing before convert"
|
||||
default = "Enforcing"
|
||||
export = "Enforcing before export"
|
||||
input = "Enforcing on import"
|
||||
print = "Enforcing before print"
|
||||
|
||||
[policies.fields]
|
||||
selectedCount = "{{count}} selected"
|
||||
|
||||
[policies.pii]
|
||||
account = "Account numbers (labelled)"
|
||||
card = "Credit / debit cards"
|
||||
email = "Email addresses"
|
||||
fieldLabel = "PII to redact"
|
||||
iban = "IBANs"
|
||||
phone = "Phone numbers"
|
||||
placeholder = "Select PII types"
|
||||
routing = "US routing numbers (ABA)"
|
||||
ssn = "Social Security numbers"
|
||||
|
||||
[policies.sidebar]
|
||||
activeCount = "{{count}} active"
|
||||
@@ -5992,6 +6020,11 @@ setup = "Set up"
|
||||
enableAriaLabel = "Enable {{tool}}"
|
||||
infoAriaLabel = "What does {{tool}} do?"
|
||||
|
||||
[policies.toolConfig.info]
|
||||
redact = "Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document."
|
||||
sanitize = "Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it."
|
||||
watermark = "Stamps a visible mark (e.g. \"Confidential\") across every page."
|
||||
|
||||
[policies.wizard]
|
||||
allDocTypesDescription = "Enable the Classification policy to filter by document type."
|
||||
allDocTypesTitle = "All document types"
|
||||
@@ -8077,6 +8110,8 @@ zoomIn = "Zoom In"
|
||||
zoomOut = "Zoom Out"
|
||||
|
||||
[viewer.attachments]
|
||||
addAttachment = "Add attachment"
|
||||
close = "Close attachments"
|
||||
empty = "No attachments in this document"
|
||||
loading = "Loading attachments..."
|
||||
noDocument = "Open a PDF to view its attachments."
|
||||
@@ -8094,6 +8129,7 @@ cancelClearAll = "Cancel"
|
||||
clearAll = "Clear all comments"
|
||||
clearAllDescription = "This removes comments and replies from the sidebar while keeping any attached annotations in the document."
|
||||
clearAllTitle = "Clear all comments?"
|
||||
close = "Close comments"
|
||||
deleteAnnotationAndComment = "Delete annotation & comment"
|
||||
deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything."
|
||||
deleteTitle = "Remove annotation from comments?"
|
||||
@@ -8104,6 +8140,7 @@ moreActions = "More actions"
|
||||
nComments_one = "{{count}} comment"
|
||||
nComments_other = "{{count}} comments"
|
||||
pageLabel = "Page {{page}}"
|
||||
placingHint = "Click a page to place… (cancel)"
|
||||
removeCommentOnly = "Remove comment only"
|
||||
saveReply = "Save reply"
|
||||
title = "Comments"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"productName": "Stirling PDF",
|
||||
"mainBinaryName": "Stirling-PDF",
|
||||
"version": "2.13.2",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
@@ -12,7 +13,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Stirling-PDF",
|
||||
"title": "Stirling PDF",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
@@ -63,6 +64,9 @@
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com",
|
||||
"wix": {
|
||||
"upgradeCode": "3305fba9-7e5e-5c09-bc71-eca0a65f4fee",
|
||||
"bannerPath": "windows/wix/banner.bmp",
|
||||
"dialogImagePath": "windows/wix/dialog.bmp",
|
||||
"fragmentPaths": ["windows/wix/provisioning.wxs"],
|
||||
"componentGroupRefs": ["ProvisioningComponentGroup"]
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
+32
-223
@@ -1,250 +1,59 @@
|
||||
/**
|
||||
* Reusable monthly spend-cap control.
|
||||
*
|
||||
* One inline row — preset chips, a custom-entry pill that matches the presets,
|
||||
* a "No cap" chip, and (optionally) a Save button — over a live "≈ N PDFs /
|
||||
* month" estimate. Extracted from the subscribed plan-page cap editor so the
|
||||
* exact same control drives the upgrade checkout flow.
|
||||
*
|
||||
* <h2>Currency-agnostic by design</h2>
|
||||
*
|
||||
* The control never decides a currency. It takes {@code pricePerDocMinor} +
|
||||
* {@code currency} and renders whatever it's handed: the subscribed plan page
|
||||
* passes the team's real Stripe-subscription rate/currency; the unsubscribed
|
||||
* checkout flow passes a USD rate (Stripe hasn't assigned the team a currency
|
||||
* yet) plus a {@code note} explaining the cap is editable later. When no rate
|
||||
* is supplied the estimate simply hides.
|
||||
*
|
||||
* <h2>Controlled</h2>
|
||||
*
|
||||
* Fully controlled via {@code capUsd} ({@code null} = no cap, {@code 0} = a
|
||||
* real $0 cap that keeps everything free) + {@code onChange}. The parent owns
|
||||
* the working value. When {@code onSave} is provided the control renders the
|
||||
* inline Save button and computes "dirty" against {@code savedCapUsd}.
|
||||
* Editor cloud adapter over the shared {@code @shared/billing} spend-cap control:
|
||||
* supplies the i18n copy (the shared control is copy-agnostic) and the editor's
|
||||
* {@code scc-*} styling. The public API (controlled {@code capUsd}/{@code
|
||||
* onChange}, optional {@code onSave}/{@code saveLabel}, {@code note}) is
|
||||
* unchanged, so the plan-page cap editor and the upgrade-checkout flow keep
|
||||
* consuming it as before.
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DEFAULT_CAP_PRESETS,
|
||||
SpendCapControl as SharedSpendCapControl,
|
||||
} from "@shared/billing";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./SpendCapControl.css";
|
||||
|
||||
// Quick amounts offered everywhere — recognition over recall.
|
||||
export const DEFAULT_CAP_PRESETS = [500, 1000, 2500, 5000] as const;
|
||||
export { DEFAULT_CAP_PRESETS };
|
||||
|
||||
export interface SpendCapControlProps {
|
||||
/** Current cap in major currency units; {@code null} = no cap. Controlled. */
|
||||
capUsd: number | null;
|
||||
/** Working-value setter. {@code null} signals no-cap. */
|
||||
onChange: (capUsd: number | null) => void;
|
||||
/** Per-document rate in minor units; null/0 hides the estimate. May be fractional. */
|
||||
pricePerDocMinor?: number | null;
|
||||
/** Lower-case ISO currency of the rate; pairs with {@link #pricePerDocMinor}. */
|
||||
currency?: string | null;
|
||||
/** Quick-amount presets (major units). Defaults to {@link DEFAULT_CAP_PRESETS}. */
|
||||
presets?: readonly number[];
|
||||
/**
|
||||
* When provided, the control renders an inline Save button. Receives whole
|
||||
* major units, or {@code null} for no-cap.
|
||||
*/
|
||||
onSave?: (capUsd: number | null) => Promise<void> | void;
|
||||
/** Label for the Save button. */
|
||||
saveLabel?: string;
|
||||
/**
|
||||
* The persisted value to diff against for the dirty check. Same encoding as
|
||||
* {@link #capUsd} ({@code null} = persisted no-cap). Only used with
|
||||
* {@link #onSave}.
|
||||
*/
|
||||
savedCapUsd?: number | null;
|
||||
/** Quiet helper line under the estimate (e.g. the USD / editable-later note). */
|
||||
note?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Format minor units of an ISO currency ("$2.24", "£0.40"). */
|
||||
function formatMinor(
|
||||
minor: number,
|
||||
currency: string | null | undefined,
|
||||
): string {
|
||||
const code = (currency ?? "usd").toUpperCase();
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
// Per-doc rates are often sub-cent (e.g. $0.02 → 2 minor, but a half-cent
|
||||
// rate is 0.5). Allow up to 3 fraction digits so they don't round to $0.
|
||||
maximumFractionDigits: 3,
|
||||
}).format(minor / 100);
|
||||
} catch {
|
||||
return `${(minor / 100).toFixed(2)} ${code}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Currency symbol for compact inline use; falls back to the ISO code. */
|
||||
function currencySymbol(currency: string | null | undefined): string {
|
||||
switch ((currency ?? "").toLowerCase()) {
|
||||
case "usd":
|
||||
case "":
|
||||
return "$";
|
||||
case "eur":
|
||||
return "€";
|
||||
case "gbp":
|
||||
return "£";
|
||||
default:
|
||||
return currency!.toUpperCase() + " ";
|
||||
}
|
||||
}
|
||||
|
||||
const SpendCapControl: React.FC<SpendCapControlProps> = ({
|
||||
capUsd,
|
||||
onChange,
|
||||
pricePerDocMinor,
|
||||
currency,
|
||||
presets = DEFAULT_CAP_PRESETS,
|
||||
onSave,
|
||||
saveLabel,
|
||||
savedCapUsd,
|
||||
note,
|
||||
...rest
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const sym = currencySymbol(currency);
|
||||
const isNoCap = capUsd === null;
|
||||
const presetSelected = capUsd != null && presets.includes(capUsd);
|
||||
// Custom is "active" when a cap is set that isn't one of the presets — i.e.
|
||||
// the value came from the custom pill.
|
||||
const customActive = capUsd != null && !presets.includes(capUsd);
|
||||
|
||||
// Local mirror of the custom field's text so partial/empty entry doesn't get
|
||||
// clobbered by the controlled value. Seeded from a non-preset incoming cap.
|
||||
const [customText, setCustomText] = useState<string>(
|
||||
customActive ? String(capUsd) : "",
|
||||
);
|
||||
|
||||
// Mirror of the backend's docCapForMoney: floor(capMinor / rate). The
|
||||
// one-time free grant is a separate lifetime pool and is NOT added here —
|
||||
// this is the paid PDFs the monthly cap buys.
|
||||
const rate =
|
||||
pricePerDocMinor != null && pricePerDocMinor > 0 ? pricePerDocMinor : null;
|
||||
const previewDocs =
|
||||
capUsd != null && rate != null ? Math.floor((capUsd * 100) / rate) : null;
|
||||
|
||||
const dirty = onSave != null && capUsd !== (savedCapUsd ?? null);
|
||||
|
||||
const selectPreset = (preset: number) => {
|
||||
setCustomText("");
|
||||
onChange(preset);
|
||||
};
|
||||
const selectNoCap = () => {
|
||||
setCustomText("");
|
||||
onChange(null);
|
||||
};
|
||||
const onCustomInput = (raw: string) => {
|
||||
// Digits only; an empty field reads as "no custom value yet" → 0 so the
|
||||
// estimate still renders sensibly without flipping to no-cap.
|
||||
const cleaned = raw.replace(/[^0-9]/g, "");
|
||||
setCustomText(cleaned);
|
||||
const v = cleaned === "" ? 0 : parseInt(cleaned, 10);
|
||||
onChange(Number.isNaN(v) ? 0 : v);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(isNoCap ? null : Math.round(capUsd ?? 0));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scc">
|
||||
<div className="scc-row">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
className="scc-chip"
|
||||
data-selected={presetSelected && capUsd === preset}
|
||||
onClick={() => selectPreset(preset)}
|
||||
>
|
||||
{sym}
|
||||
{preset.toLocaleString()}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Custom-entry pill — dashed until it carries a value, then it fills
|
||||
like a selected chip. */}
|
||||
<label className="scc-custom" data-active={customActive}>
|
||||
<span className="scc-custom__symbol">{sym}</span>
|
||||
<input
|
||||
className="scc-custom__input"
|
||||
inputMode="numeric"
|
||||
value={customActive ? customText : ""}
|
||||
placeholder={t("payg.cap.custom", "Custom")}
|
||||
aria-label={t("payg.cap.amount", "Cap amount")}
|
||||
onChange={(e) => onCustomInput(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`scc-chip${onSave ? "" : " scc-row__spacer"}`}
|
||||
data-selected={isNoCap}
|
||||
onClick={selectNoCap}
|
||||
>
|
||||
{t("payg.cap.noCapLabel", "No cap")}
|
||||
</button>
|
||||
|
||||
{onSave && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
className="scc-row__spacer"
|
||||
disabled={!dirty || saving}
|
||||
loading={saving}
|
||||
leftSection={<LocalIcon icon="check-rounded" />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saveLabel ?? t("payg.cap.save", "Update cap")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewDocs != null && (
|
||||
<div className="scc-estimate">
|
||||
<DescriptionIcon
|
||||
className="scc-estimate__icon"
|
||||
sx={{ fontSize: 22 }}
|
||||
/>
|
||||
<div>
|
||||
<div className="scc-estimate__main">
|
||||
{t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
|
||||
docs: previewDocs.toLocaleString(),
|
||||
})}
|
||||
</div>
|
||||
<div className="scc-estimate__sub">
|
||||
{t("payg.cap.docsRate", "at {{rate}} / PDF", {
|
||||
rate: formatMinor(pricePerDocMinor ?? 0, currency),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNoCap && (
|
||||
<div className="scc-note">
|
||||
{t(
|
||||
"payg.cap.noCapDesc",
|
||||
"Usage is billed without an upper limit. You can re-enable a cap at any time.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{note && <div className="scc-note">{note}</div>}
|
||||
</div>
|
||||
<SharedSpendCapControl
|
||||
{...rest}
|
||||
labels={{
|
||||
custom: t("payg.cap.custom", "Custom"),
|
||||
amountAria: t("payg.cap.amount", "Cap amount"),
|
||||
noCap: t("payg.cap.noCapLabel", "No cap"),
|
||||
save: saveLabel ?? t("payg.cap.save", "Update cap"),
|
||||
docsEstimate: (docs) =>
|
||||
t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
|
||||
docs,
|
||||
}),
|
||||
docsRate: (rate) =>
|
||||
t("payg.cap.docsRate", "at {{rate}} / PDF", { rate }),
|
||||
noCapDesc: t(
|
||||
"payg.cap.noCapDesc",
|
||||
"Usage is billed without an upper limit. You can re-enable a cap at any time.",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,36 +7,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWallet, type Wallet } from "@app/hooks/useWallet";
|
||||
import { currencySymbol, MeterBar, meterState } from "@shared/billing";
|
||||
import "@app/components/shared/config/configSections/Payg.css";
|
||||
import "@app/components/shared/config/configSections/PaygFree.css";
|
||||
|
||||
export type MeterState = "FULL" | "WARNED" | "DEGRADED";
|
||||
|
||||
/** Warn/degrade band for a usage meter (mirrors the BE thresholds). */
|
||||
export function meterState(
|
||||
used: number,
|
||||
limit: number,
|
||||
): { state: MeterState; pct: number } {
|
||||
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100;
|
||||
const state: MeterState =
|
||||
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
|
||||
return { state, pct };
|
||||
}
|
||||
|
||||
/** Currency symbol for compact inline use; falls back to the ISO code. */
|
||||
function currencySymbol(currency: string | null): string {
|
||||
switch ((currency ?? "").toLowerCase()) {
|
||||
case "usd":
|
||||
return "$";
|
||||
case "eur":
|
||||
return "€";
|
||||
case "gbp":
|
||||
return "£";
|
||||
default:
|
||||
return currency ? currency.toUpperCase() + " " : "$";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── One-time free grant meter ──────────────────────────────────────────────
|
||||
|
||||
export interface FreeSnapshot {
|
||||
@@ -78,38 +52,20 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
||||
: t("payg.free.state.plentyLeft", "Plenty left");
|
||||
|
||||
return (
|
||||
<div className="paygf-meter" data-state={state}>
|
||||
<div className="paygf-meter__top">
|
||||
<div className="paygf-meter__figure">
|
||||
<span className="paygf-meter__num">
|
||||
{snap.billableUsed.toLocaleString()}
|
||||
</span>
|
||||
<span className="paygf-meter__cap">
|
||||
{t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
|
||||
limit: snap.billableLimit.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<span className="payg-status" data-state={state}>
|
||||
<span className="payg-status__dot" />
|
||||
{stateLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="payg-bar">
|
||||
<div
|
||||
className="payg-bar__fill"
|
||||
data-state={state}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="paygf-meter__meta">
|
||||
<MeterBar
|
||||
state={state}
|
||||
pct={pct}
|
||||
figure={snap.billableUsed.toLocaleString()}
|
||||
capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
|
||||
limit: snap.billableLimit.toLocaleString(),
|
||||
})}
|
||||
statusLabel={stateLabel}
|
||||
meta={
|
||||
<span>
|
||||
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,45 +114,28 @@ export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) {
|
||||
const symbol = currencySymbol(snap.currency);
|
||||
|
||||
return (
|
||||
<div className="paygf-meter" data-state={state}>
|
||||
<div className="paygf-meter__top">
|
||||
<div className="paygf-meter__figure">
|
||||
<span className="paygf-meter__num">
|
||||
{symbol}
|
||||
{snap.spent.toLocaleString()}
|
||||
<MeterBar
|
||||
state={state}
|
||||
pct={pct}
|
||||
figure={`${symbol}${snap.spent.toLocaleString()}`}
|
||||
capSuffix={t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", {
|
||||
amount: `${symbol}${snap.cap.toLocaleString()}`,
|
||||
})}
|
||||
statusLabel={stateLabel}
|
||||
meta={
|
||||
<>
|
||||
<span>
|
||||
{t(
|
||||
"payg.spendCapMeter.metaCategories",
|
||||
"Automation · AI · API spend",
|
||||
)}
|
||||
</span>
|
||||
<span className="paygf-meter__cap">
|
||||
{t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", {
|
||||
amount: `${symbol}${snap.cap.toLocaleString()}`,
|
||||
})}
|
||||
<span className="payg-hero__meta-dot">•</span>
|
||||
<span>
|
||||
{t("payg.spendCapMeter.resets", "Resets each billing period")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="payg-status" data-state={state}>
|
||||
<span className="payg-status__dot" />
|
||||
{stateLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="payg-bar">
|
||||
<div
|
||||
className="payg-bar__fill"
|
||||
data-state={state}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="paygf-meter__meta">
|
||||
<span>
|
||||
{t(
|
||||
"payg.spendCapMeter.metaCategories",
|
||||
"Automation · AI · API spend",
|
||||
)}
|
||||
</span>
|
||||
<span className="payg-hero__meta-dot">•</span>
|
||||
<span>
|
||||
{t("payg.spendCapMeter.resets", "Resets each billing period")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,123 +50,26 @@ import apiClient from "@app/services/apiClient";
|
||||
import { createPortalSession } from "@app/services/billing";
|
||||
import { openExternal } from "@app/platform/openExternal";
|
||||
import { getWalletDevPreview } from "@app/hooks/walletDevPreview";
|
||||
import type {
|
||||
Wallet,
|
||||
WalletStatus,
|
||||
WalletRole,
|
||||
WalletMember,
|
||||
WalletCategoryBreakdown,
|
||||
WalletActivityRow,
|
||||
} from "@shared/billing";
|
||||
|
||||
// ─── Public types ───────────────────────────────────────────────────────
|
||||
|
||||
export type WalletStatus = "free" | "subscribed";
|
||||
export type WalletRole = "leader" | "member";
|
||||
|
||||
/**
|
||||
* A single team member's billing-relevant info — name + email for the avatar
|
||||
* row, {@code spendUnits} for their per-member usage display. Mirrors a row of
|
||||
* the backend's {@code members} array on {@code WalletSnapshot} (joined with
|
||||
* {@code team_memberships}).
|
||||
*/
|
||||
export interface WalletMember {
|
||||
/** Supabase user id of the member. */
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
/** Member's current-period billable spend. */
|
||||
spendUnits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-category breakdown of current-period spend in billable units. The
|
||||
* categories mirror the {@code FeatureGate} buckets the backend tracks:
|
||||
* server-side tool calls ({@code api}), AI-backed tools ({@code ai}), and
|
||||
* pipeline / automation runs ({@code automation}). Numbers sum to {@code
|
||||
* billableUsed} (modulo rounding in mock data).
|
||||
*/
|
||||
export interface WalletCategoryBreakdown {
|
||||
api: number;
|
||||
ai: number;
|
||||
automation: number;
|
||||
}
|
||||
|
||||
/** Mirror of the backend's {@code WalletSnapshot} record (the JSON returned from {@code GET /api/v1/payg/wallet}). */
|
||||
export interface Wallet {
|
||||
/**
|
||||
* The caller's primary team_id. Needed when invoking Supabase edge functions
|
||||
* (create-checkout-session, etc.) that run outside Spring Security and have
|
||||
* no other way to resolve the caller's team. May be null on the synthetic
|
||||
* empty snapshot returned to anonymous / team-less callers.
|
||||
*/
|
||||
teamId: number | null;
|
||||
status: WalletStatus;
|
||||
role: WalletRole;
|
||||
/**
|
||||
* ISO yyyy-mm-dd. The Stripe subscription's current period when subscribed;
|
||||
* the calendar month for free teams.
|
||||
*/
|
||||
billingPeriodStart: string;
|
||||
billingPeriodEnd: string;
|
||||
/**
|
||||
* For a free team: the one-time free documents used so far ({@code
|
||||
* freeAllowance − freeRemaining}). For a subscribed team: documents
|
||||
* processed this month across automation + AI + API.
|
||||
*/
|
||||
billableUsed: number;
|
||||
/**
|
||||
* The team's document ceiling for the matching window: the one-time free
|
||||
* grant ({@code freeAllowance}) for free teams; the monthly paid-doc cap
|
||||
* {@code floor(cap / perDocRate)} for capped subscribed teams; null when
|
||||
* subscribed with no cap (uncapped).
|
||||
*/
|
||||
billableLimit: number | null;
|
||||
/**
|
||||
* The team's one-time free document grant size — the "N" in "X of N free".
|
||||
* A lifetime grant ({@code pricing_policy.free_tier_units}): it never resets
|
||||
* and is not lost when the team subscribes.
|
||||
*/
|
||||
freeAllowance: number;
|
||||
/**
|
||||
* One-time free documents still available to the team
|
||||
* ({@code payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
|
||||
* Survives subscribing — a subscribed team keeps any unused grant.
|
||||
*/
|
||||
freeRemaining: number;
|
||||
/**
|
||||
* Paid per-document rate in minor units of {@link Wallet#currency} (may be
|
||||
* fractional); null when the rate can't be resolved — render "unknown",
|
||||
* never substitute.
|
||||
*/
|
||||
pricePerDocMinor: number | null;
|
||||
/** Lower-case ISO 4217 currency of the subscription's Stripe Price; null when unknown. */
|
||||
currency: string | null;
|
||||
/**
|
||||
* Estimated charges so far this period in minor units of currency: paid
|
||||
* (Stripe-metered) documents this period × rate. The free portion was
|
||||
* already netted out at charge time. Informational — the Stripe invoice
|
||||
* is authoritative. Null when the rate is unknown.
|
||||
*/
|
||||
estimatedBillMinor: number | null;
|
||||
/** Monthly cap in major currency units when subscribed; null when noCap or status=='free'. */
|
||||
capUsd: number | null;
|
||||
/** Only meaningful when status=='subscribed'. */
|
||||
noCap: boolean;
|
||||
/** Stripe subscription id when subscribed; null when free. */
|
||||
stripeSubscriptionId: string | null;
|
||||
/** Current-period spend in billable units. */
|
||||
spendUnitsThisPeriod: number;
|
||||
/** Per-category spend breakdown (api / ai / automation). */
|
||||
categoryBreakdown: WalletCategoryBreakdown;
|
||||
/**
|
||||
* Team members, populated for the leader view; empty for members or
|
||||
* single-seat tenants. Leader-vs-member is still resolved via {@link
|
||||
* Wallet#role} — this field just carries the per-member rows the leader's
|
||||
* sub-cap table needs.
|
||||
*/
|
||||
members: WalletMember[];
|
||||
/**
|
||||
* Recent billable-activity rows. V1 returns {@code []} from the backend;
|
||||
* the field exists so the Plan page can render an empty state without
|
||||
* branching on undefined. Each entry is a {@code Record<string, unknown>}
|
||||
* because the activity-row shape is not yet finalised — when the meter-
|
||||
* event surface lands, this widens to a real interface.
|
||||
*/
|
||||
recent: Array<Record<string, unknown>>;
|
||||
}
|
||||
// The wallet contract lives in @shared/billing (shared with the admin portal).
|
||||
// Re-exported so existing `@app/hooks/useWallet` importers keep their imports.
|
||||
export type {
|
||||
Wallet,
|
||||
WalletStatus,
|
||||
WalletRole,
|
||||
WalletMember,
|
||||
WalletCategoryBreakdown,
|
||||
WalletActivityRow,
|
||||
};
|
||||
|
||||
export interface UseWalletResult {
|
||||
wallet: Wallet | null;
|
||||
|
||||
@@ -989,6 +989,35 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
</div>
|
||||
) : filteredFileStubs.length > 0 ? (
|
||||
<div className="file-sidebar-file-list">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nativeFileInputRef.current?.click()}
|
||||
data-testid="add-files-row"
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-muted)",
|
||||
padding: "4px 6px",
|
||||
marginBottom: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--hover-bg)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: "0.9rem" }} />
|
||||
{t("fileSidebar.addFiles", "Add files")}
|
||||
</button>
|
||||
{filteredFileStubs.map((stub) => {
|
||||
const workbenchFileId = state.files.ids.find(
|
||||
(id) => (id as string) === (stub.id as string),
|
||||
|
||||
@@ -30,6 +30,9 @@ import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import { enforceExportPolicies } from "@app/services/policyExport";
|
||||
import { downloadFile as downloadRaw } from "@app/services/downloadService";
|
||||
import { alert as showAlert } from "@app/components/toast";
|
||||
import {
|
||||
WorkbenchBarButtonConfig,
|
||||
WorkbenchBarRenderContext,
|
||||
@@ -171,13 +174,35 @@ export default function WorkbenchBar({
|
||||
|
||||
const filesToExport =
|
||||
selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
for (const file of filesToExport) {
|
||||
const stub = isStirlingFile(file)
|
||||
const stubs = filesToExport.map((file) =>
|
||||
isStirlingFile(file)
|
||||
? selectors.getStirlingFileStub(file.fileId)
|
||||
: undefined;
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Enforce all files in one batch so the toast shows progress across the
|
||||
// whole set (e.g. "report.pdf (2 of 5)") rather than N invisible solo runs.
|
||||
let enforced: File[];
|
||||
try {
|
||||
enforced = await enforceExportPolicies(
|
||||
filesToExport as File[],
|
||||
stubs.map((s) => s?.id),
|
||||
);
|
||||
} catch {
|
||||
enforced = filesToExport as File[];
|
||||
showAlert({
|
||||
alertType: "warning",
|
||||
title: t("policies.enforcement.exportFailureTitle"),
|
||||
body: t("policies.enforcement.exportFailureBody"),
|
||||
});
|
||||
}
|
||||
|
||||
for (let idx = 0; idx < filesToExport.length; idx++) {
|
||||
const file = filesToExport[idx];
|
||||
const stub = stubs[idx];
|
||||
try {
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
const result = await downloadRaw({
|
||||
data: enforced[idx],
|
||||
filename: file.name,
|
||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||
fileId: stub?.id,
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
ScrollArea,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Button,
|
||||
Loader,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { PdfAttachmentObject } from "@embedpdf/models";
|
||||
import AttachmentIcon from "@mui/icons-material/AttachmentRounded";
|
||||
import DownloadIcon from "@mui/icons-material/DownloadRounded";
|
||||
@@ -52,7 +54,9 @@ export const AttachmentSidebar = ({
|
||||
preloadCacheKeys = [],
|
||||
}: AttachmentSidebarProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { attachmentActions, hasAttachmentSupport } = useViewer();
|
||||
const { attachmentActions, hasAttachmentSupport, toggleAttachmentSidebar } =
|
||||
useViewer();
|
||||
const { handleToolSelectForced } = useToolWorkflow();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [attachmentSupport, setAttachmentSupport] = useState(() =>
|
||||
hasAttachmentSupport(),
|
||||
@@ -139,16 +143,28 @@ export const AttachmentSidebar = ({
|
||||
|
||||
const key = documentCacheKey;
|
||||
const cached = cacheRef.current.get(key);
|
||||
if (
|
||||
cached &&
|
||||
(cached.status === "loading" || cached.status === "success")
|
||||
) {
|
||||
// Only short-circuit on a finalised success cache. Skipping when
|
||||
// cached.status === "loading" caused the sidebar to get stuck: if
|
||||
// the previous fetch was cancelled (by a parent re-render that
|
||||
// changed the attachmentActions reference - createViewerActions
|
||||
// builds a new object every viewer render), the cache still says
|
||||
// "loading" but no live fetch is in flight. On the re-run we'd
|
||||
// early-return and never refetch, so the UI would sit on the
|
||||
// "Loading attachments..." state forever. Same change applied in
|
||||
// BookmarkSidebar.
|
||||
if (cached && cached.status === "success") {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
// Don't write "loading" into the cache - keep the cache for
|
||||
// terminal states (success/error) only, so a cancelled run can
|
||||
// never leave a stale "loading" entry behind. The visible
|
||||
// sidebar state still goes through setActiveEntry below.
|
||||
const updateEntry = (entry: AttachmentCacheEntry) => {
|
||||
cacheRef.current.set(key, entry);
|
||||
if (entry.status === "success" || entry.status === "error") {
|
||||
cacheRef.current.set(key, entry);
|
||||
}
|
||||
if (!cancelled && currentKeyRef.current === key) {
|
||||
setActiveEntry(entry);
|
||||
}
|
||||
@@ -163,10 +179,20 @@ export const AttachmentSidebar = ({
|
||||
);
|
||||
|
||||
const fetchWithRetry = async () => {
|
||||
const maxAttempts = 10;
|
||||
// See BookmarkSidebar - matching change. After a file swap the
|
||||
// attachment bridge briefly unregisters and the action returns
|
||||
// null until the new document is loaded; without retrying on
|
||||
// null we'd cache an empty success and miss freshly-added
|
||||
// attachments.
|
||||
const maxAttempts = 30;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const result = await attachmentActions.getAttachments();
|
||||
if (result === null) {
|
||||
if (attempt === maxAttempts - 1) return [];
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
continue;
|
||||
}
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
@@ -239,6 +265,14 @@ export const AttachmentSidebar = ({
|
||||
attachmentActions.downloadAttachment(attachment);
|
||||
};
|
||||
|
||||
const handleAddAttachment = useCallback(() => {
|
||||
// Close the attachment sidebar before opening the tool so the user
|
||||
// doesn't end up looking at two stacked side panels (the sidebar on
|
||||
// the right + the tool's settings on the left).
|
||||
toggleAttachmentSidebar();
|
||||
handleToolSelectForced("addAttachments");
|
||||
}, [handleToolSelectForced, toggleAttachmentSidebar]);
|
||||
|
||||
const filteredAttachments = useMemo(() => {
|
||||
const attachments = Array.isArray(activeEntry.attachments)
|
||||
? activeEntry.attachments
|
||||
@@ -352,6 +386,18 @@ export const AttachmentSidebar = ({
|
||||
{t("viewer.attachments.title", "Attachments")}
|
||||
</Text>
|
||||
</div>
|
||||
<Box style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={toggleAttachmentSidebar}
|
||||
aria-label="Close attachments sidebar"
|
||||
title={t("viewer.attachments.close", "Close attachments")}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<Box
|
||||
@@ -427,20 +473,56 @@ export const AttachmentSidebar = ({
|
||||
)}
|
||||
|
||||
{showEmptyState && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
<Stack align="center" gap="sm" py="lg">
|
||||
<LocalIcon
|
||||
icon="attachment-rounded"
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
style={{ color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"viewer.attachments.empty",
|
||||
"No attachments in this document",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={handleAddAttachment}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="1rem" height="1rem" />
|
||||
}
|
||||
>
|
||||
{t("viewer.attachments.addAttachment", "Add attachment")}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{showAttachmentList && (
|
||||
<div className="attachment-list">
|
||||
{renderAttachments(filteredAttachments)}
|
||||
</div>
|
||||
<>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
fullWidth
|
||||
onClick={handleAddAttachment}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
mb="xs"
|
||||
styles={{
|
||||
root: {
|
||||
justifyContent: "flex-start",
|
||||
paddingInline: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("viewer.attachments.addAttachment", "Add attachment")}
|
||||
</Button>
|
||||
<div className="attachment-list">
|
||||
{renderAttachments(filteredAttachments)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSearchEmpty && (
|
||||
|
||||
@@ -7,10 +7,18 @@ import {
|
||||
Loader,
|
||||
Stack,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Button,
|
||||
Group,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
import { isStirlingFile, type FileId } from "@app/types/fileContext";
|
||||
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { PdfBookmarkObject, PdfActionType } from "@embedpdf/models";
|
||||
import BookmarksIcon from "@mui/icons-material/BookmarksRounded";
|
||||
import "@app/components/viewer/SidebarBase.css";
|
||||
@@ -75,9 +83,25 @@ export const BookmarkSidebar = ({
|
||||
documentCacheKey,
|
||||
preloadCacheKeys = [],
|
||||
}: BookmarkSidebarProps) => {
|
||||
const { bookmarkActions, scrollActions, hasBookmarkSupport } = useViewer();
|
||||
const {
|
||||
bookmarkActions,
|
||||
scrollActions,
|
||||
hasBookmarkSupport,
|
||||
activeFileId,
|
||||
activeFileIndex,
|
||||
setActiveFileId,
|
||||
getScrollState,
|
||||
toggleBookmarkSidebar,
|
||||
} = useViewer();
|
||||
const { handleToolSelectForced } = useToolWorkflow();
|
||||
const { selectors, actions: fileActions } = useFileContext();
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isAddingBookmark, setIsAddingBookmark] = useState(false);
|
||||
const [newBookmarkTitle, setNewBookmarkTitle] = useState("");
|
||||
const [newBookmarkPage, setNewBookmarkPage] = useState<number>(1);
|
||||
const [isSavingBookmark, setIsSavingBookmark] = useState(false);
|
||||
const [addBookmarkError, setAddBookmarkError] = useState<string | null>(null);
|
||||
const [bookmarkSupport, setBookmarkSupport] = useState(() =>
|
||||
hasBookmarkSupport(),
|
||||
);
|
||||
@@ -164,16 +188,23 @@ export const BookmarkSidebar = ({
|
||||
|
||||
const key = documentCacheKey;
|
||||
const cached = cacheRef.current.get(key);
|
||||
if (
|
||||
cached &&
|
||||
(cached.status === "loading" || cached.status === "success")
|
||||
) {
|
||||
// Only short-circuit on a finalised success cache. Skipping when
|
||||
// cached.status === "loading" causes the sidebar to get stuck if
|
||||
// the previous fetch was cancelled by a parent re-render (the
|
||||
// bookmarkActions reference changes every viewer render because
|
||||
// createViewerActions rebuilds the object). See matching change
|
||||
// in AttachmentSidebar.
|
||||
if (cached && cached.status === "success") {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
// Don't write "loading" into the cache - cache only terminal
|
||||
// states so a cancelled run can't poison the cache.
|
||||
const updateEntry = (entry: BookmarkCacheEntry) => {
|
||||
cacheRef.current.set(key, entry);
|
||||
if (entry.status === "success" || entry.status === "error") {
|
||||
cacheRef.current.set(key, entry);
|
||||
}
|
||||
if (!cancelled && currentKeyRef.current === key) {
|
||||
setActiveEntry(entry);
|
||||
}
|
||||
@@ -188,10 +219,24 @@ export const BookmarkSidebar = ({
|
||||
);
|
||||
|
||||
const fetchWithRetry = async () => {
|
||||
const maxAttempts = 10;
|
||||
// 30 × 50ms = 1.5s window. After consumeFiles swaps the file the
|
||||
// embedpdf bookmark plugin tears down for the old document and
|
||||
// re-registers for the new one; until the bridge is back the
|
||||
// action returns null. Without retrying on null we'd cache an
|
||||
// empty "success" and the just-added bookmark would never show
|
||||
// up in the sidebar.
|
||||
const maxAttempts = 30;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const result = await bookmarkActions.fetchBookmarks();
|
||||
if (result === null) {
|
||||
// Bridge not registered yet (document still loading). Wait
|
||||
// and retry instead of caching this as a successful empty
|
||||
// list.
|
||||
if (attempt === maxAttempts - 1) return [];
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
continue;
|
||||
}
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
@@ -256,6 +301,143 @@ export const BookmarkSidebar = ({
|
||||
setFetchNonce((value) => value + 1);
|
||||
}, [documentCacheKey, bookmarkActions]);
|
||||
|
||||
const handleOpenAddBookmark = useCallback(() => {
|
||||
setAddBookmarkError(null);
|
||||
setNewBookmarkTitle("");
|
||||
// Default the new bookmark's target page to whatever page the user is
|
||||
// currently viewing - matches Acrobat / Foxit behaviour.
|
||||
const currentPage = getScrollState?.()?.currentPage ?? 1;
|
||||
setNewBookmarkPage(currentPage);
|
||||
setIsAddingBookmark(true);
|
||||
}, [getScrollState]);
|
||||
|
||||
const handleCancelAddBookmark = useCallback(() => {
|
||||
setIsAddingBookmark(false);
|
||||
setAddBookmarkError(null);
|
||||
setNewBookmarkTitle("");
|
||||
}, []);
|
||||
|
||||
// Fallback: open the full Edit Table of Contents tool when inline add is
|
||||
// not viable (e.g. the active file is a preview / unmanaged file we
|
||||
// cannot consume + replace via FileContext).
|
||||
const handleFallbackToTool = useCallback(() => {
|
||||
handleToolSelectForced("editTableOfContents");
|
||||
}, [handleToolSelectForced]);
|
||||
|
||||
const handleSubmitAddBookmark = useCallback(async () => {
|
||||
const title = newBookmarkTitle.trim();
|
||||
if (!title) {
|
||||
setAddBookmarkError("Bookmark title is required");
|
||||
return;
|
||||
}
|
||||
// Resolve the file the viewer is currently displaying. activeFileId
|
||||
// is only set explicitly (user clicked a thumbnail / a tool ran);
|
||||
// on a fresh /read upload it stays null and the viewer falls back
|
||||
// to activeFileIndex - so we mirror that here. Without this, Save
|
||||
// would silently route to the full editor every time on a fresh
|
||||
// upload.
|
||||
const allFiles = selectors.getFiles();
|
||||
const resolvedFile = activeFileId
|
||||
? allFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId)
|
||||
: (allFiles[activeFileIndex] ?? allFiles[0]);
|
||||
const resolvedFileId =
|
||||
resolvedFile && isStirlingFile(resolvedFile)
|
||||
? (resolvedFile.fileId as FileId)
|
||||
: null;
|
||||
if (!resolvedFileId) {
|
||||
handleFallbackToTool();
|
||||
return;
|
||||
}
|
||||
const fileId = resolvedFileId;
|
||||
const file = selectors.getFile(fileId);
|
||||
const parentStub = selectors.getStirlingFileStub(fileId);
|
||||
if (!file || !parentStub) {
|
||||
handleFallbackToTool();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingBookmark(true);
|
||||
setAddBookmarkError(null);
|
||||
try {
|
||||
// Convert existing PDF bookmarks (from embedpdf) to the backend's
|
||||
// payload shape, then append the new one.
|
||||
const toPayload = (
|
||||
b: PdfBookmarkObject,
|
||||
): {
|
||||
title: string;
|
||||
pageNumber: number;
|
||||
children: any[];
|
||||
} => ({
|
||||
title: b.title ?? "",
|
||||
pageNumber: resolvePageNumber(b) ?? 1,
|
||||
children: (b.children ?? []).map(toPayload),
|
||||
});
|
||||
const existing = (activeEntry.bookmarks ?? []).map(toPayload);
|
||||
const bookmarkData = [
|
||||
...existing,
|
||||
{ title, pageNumber: newBookmarkPage, children: [] },
|
||||
];
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("replaceExisting", "true");
|
||||
formData.append("bookmarkData", JSON.stringify(bookmarkData));
|
||||
|
||||
const response = await apiClient.post(
|
||||
"/api/v1/general/edit-table-of-contents",
|
||||
formData,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
|
||||
const newFile = new File([response.data as Blob], file.name, {
|
||||
type: "application/pdf",
|
||||
});
|
||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(
|
||||
[newFile],
|
||||
parentStub,
|
||||
"editTableOfContents",
|
||||
);
|
||||
const outputFileIds = await fileActions.consumeFiles(
|
||||
[fileId],
|
||||
stirlingFiles,
|
||||
stubs,
|
||||
);
|
||||
|
||||
// Point the viewer at the new file. Without this the viewer's
|
||||
// activeFileId-removed effect nulls activeFileId (old file is
|
||||
// gone) and the activeFileIndex falls back to 0, which races
|
||||
// against the embedpdf plugin reloading - the bookmark /
|
||||
// attachment bridges can end up stuck in a "loading" state.
|
||||
// useToolOperation does the same thing after consumeFiles.
|
||||
if (outputFileIds.length === 1) {
|
||||
setActiveFileId(outputFileIds[0]);
|
||||
}
|
||||
|
||||
// Reset form. The cache is keyed by documentCacheKey (== fileId);
|
||||
// the new fileId triggers our document-switch effect, which
|
||||
// resets state and re-fetches once the embedpdf bookmark
|
||||
// capability has the new document loaded.
|
||||
setIsAddingBookmark(false);
|
||||
setNewBookmarkTitle("");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to save bookmark";
|
||||
setAddBookmarkError(message);
|
||||
} finally {
|
||||
setIsSavingBookmark(false);
|
||||
}
|
||||
}, [
|
||||
newBookmarkTitle,
|
||||
newBookmarkPage,
|
||||
activeFileId,
|
||||
activeFileIndex,
|
||||
selectors,
|
||||
fileActions,
|
||||
setActiveFileId,
|
||||
activeEntry.bookmarks,
|
||||
handleFallbackToTool,
|
||||
]);
|
||||
|
||||
const bookmarksWithIds = useMemo(() => {
|
||||
const assignIds = (
|
||||
nodes: PdfBookmarkObject[],
|
||||
@@ -293,34 +475,6 @@ export const BookmarkSidebar = ({
|
||||
}));
|
||||
};
|
||||
|
||||
const expandAll = useCallback(() => {
|
||||
const allExpanded: Record<string, boolean> = {};
|
||||
const expandRecursive = (nodes: BookmarkNode[]) => {
|
||||
nodes.forEach((node) => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
allExpanded[node.id] = true;
|
||||
expandRecursive(node.children as BookmarkNode[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
expandRecursive(bookmarksWithIds);
|
||||
setExpanded(allExpanded);
|
||||
}, [bookmarksWithIds]);
|
||||
|
||||
const collapseAll = useCallback(() => {
|
||||
const allCollapsed: Record<string, boolean> = {};
|
||||
const collapseRecursive = (nodes: BookmarkNode[]) => {
|
||||
nodes.forEach((node) => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
allCollapsed[node.id] = false;
|
||||
collapseRecursive(node.children as BookmarkNode[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
collapseRecursive(bookmarksWithIds);
|
||||
setExpanded(allCollapsed);
|
||||
}, [bookmarksWithIds]);
|
||||
|
||||
const handleBookmarkClick = (
|
||||
bookmark: PdfBookmarkObject,
|
||||
event: React.MouseEvent,
|
||||
@@ -499,31 +653,18 @@ export const BookmarkSidebar = ({
|
||||
Bookmarks
|
||||
</Text>
|
||||
</div>
|
||||
{bookmarkSupport && bookmarksWithIds.length > 0 && (
|
||||
<>
|
||||
{Object.values(expanded).some((val) => val === false) ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={expandAll}
|
||||
aria-label="Expand all bookmarks"
|
||||
title="Expand all"
|
||||
>
|
||||
<LocalIcon icon="unfold-more" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={collapseAll}
|
||||
aria-label="Collapse all bookmarks"
|
||||
title="Collapse all"
|
||||
>
|
||||
<LocalIcon icon="unfold-less" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Box style={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={toggleBookmarkSidebar}
|
||||
aria-label="Close bookmarks sidebar"
|
||||
title="Close bookmarks"
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<Box
|
||||
@@ -586,18 +727,119 @@ export const BookmarkSidebar = ({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{showEmptyState && (
|
||||
<div className="sidebar-base__empty-state">
|
||||
{showEmptyState && !isAddingBookmark && (
|
||||
<Stack align="center" gap="sm" py="lg">
|
||||
<LocalIcon
|
||||
icon="bookmark-add-rounded"
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
style={{ color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No bookmarks in this document
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={handleOpenAddBookmark}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="1rem" height="1rem" />
|
||||
}
|
||||
>
|
||||
Add bookmark
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{isAddingBookmark && (
|
||||
<Box
|
||||
mb="sm"
|
||||
p="sm"
|
||||
data-testid="bookmark-add-form"
|
||||
style={{
|
||||
border: "1px solid var(--border-subtle)",
|
||||
borderRadius: 6,
|
||||
background: "var(--bg-raised, var(--mantine-color-gray-0))",
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||
Add bookmark
|
||||
</Text>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Bookmark title"
|
||||
aria-label="Bookmark title"
|
||||
value={newBookmarkTitle}
|
||||
onChange={(e) => setNewBookmarkTitle(e.currentTarget.value)}
|
||||
autoFocus
|
||||
disabled={isSavingBookmark}
|
||||
/>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
label="Page"
|
||||
min={1}
|
||||
clampBehavior="strict"
|
||||
value={newBookmarkPage}
|
||||
onChange={(v) =>
|
||||
setNewBookmarkPage(typeof v === "number" ? v : 1)
|
||||
}
|
||||
disabled={isSavingBookmark}
|
||||
/>
|
||||
{addBookmarkError && (
|
||||
<Text size="xs" c="red">
|
||||
{addBookmarkError}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={handleCancelAddBookmark}
|
||||
disabled={isSavingBookmark}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="blue"
|
||||
onClick={handleSubmitAddBookmark}
|
||||
loading={isSavingBookmark}
|
||||
disabled={!newBookmarkTitle.trim()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showBookmarkList && (
|
||||
<div className="bookmark-list">
|
||||
{renderBookmarks(filteredBookmarks)}
|
||||
</div>
|
||||
<>
|
||||
{!isAddingBookmark && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
fullWidth
|
||||
onClick={handleOpenAddBookmark}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
mb="xs"
|
||||
styles={{
|
||||
root: {
|
||||
justifyContent: "flex-start",
|
||||
paddingInline: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add bookmark
|
||||
</Button>
|
||||
)}
|
||||
<div className="bookmark-list">
|
||||
{renderBookmarks(filteredBookmarks)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSearchEmpty && (
|
||||
@@ -609,6 +851,41 @@ export const BookmarkSidebar = ({
|
||||
)}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
|
||||
{bookmarkSupport && documentCacheKey && (
|
||||
<Box
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderTop: "1px solid var(--border-subtle)",
|
||||
backgroundColor: "var(--bg-toolbar)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
onClick={handleFallbackToTool}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Group gap="xs" justify="center" wrap="nowrap">
|
||||
<LocalIcon
|
||||
icon="bookmark-add-rounded"
|
||||
width="0.95rem"
|
||||
height="0.95rem"
|
||||
style={{ color: "var(--mantine-color-blue-5)" }}
|
||||
/>
|
||||
<Text
|
||||
size="xs"
|
||||
c="blue.5"
|
||||
ta="center"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
Need to reorder or nest? Open the Bookmark Editor
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -311,11 +311,17 @@ export function CommentsSidebar({
|
||||
clearHighlightCommentRequest,
|
||||
scrollActions,
|
||||
getZoomState,
|
||||
toggleCommentsSidebar,
|
||||
} = useViewer() ?? {};
|
||||
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const { state, provides } = useAnnotation(documentId);
|
||||
const { handleToolSelectForced } = useToolWorkflow();
|
||||
const { activateAnnotationToolRef } = useAnnotationContext();
|
||||
const {
|
||||
activateAnnotationToolRef,
|
||||
activeAnnotationToolId,
|
||||
setActiveAnnotationToolId,
|
||||
} = useAnnotationContext();
|
||||
const isPlacingComment = activeAnnotationToolId === TEXT_COMMENT_TOOL_ID;
|
||||
const [draftContents, setDraftContents] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
@@ -639,12 +645,34 @@ export function CommentsSidebar({
|
||||
);
|
||||
|
||||
const handleAddComment = useCallback(() => {
|
||||
// Keep the sidebar open this time - the button morphs into a
|
||||
// "Click on a page... cancel" hint so the user can see exactly
|
||||
// what state the viewer is in.
|
||||
handleToolSelectForced(ANNOTATE_PANEL_ID);
|
||||
requestAnimationFrame(() => {
|
||||
activateAnnotationToolRef.current?.(TEXT_COMMENT_TOOL_ID);
|
||||
});
|
||||
}, [handleToolSelectForced, activateAnnotationToolRef]);
|
||||
|
||||
const handleCancelPlacingComment = useCallback(() => {
|
||||
// De-arm the textComment tool. The panel's activateAnnotationTool
|
||||
// takes the AnnotationToolId "select" to reset to no-tool state.
|
||||
activateAnnotationToolRef.current?.("select" as never);
|
||||
setActiveAnnotationToolId(null);
|
||||
}, [activateAnnotationToolRef, setActiveAnnotationToolId]);
|
||||
|
||||
// ESC cancels placement mode while the sidebar is open.
|
||||
useEffect(() => {
|
||||
if (!visible || !isPlacingComment) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
handleCancelPlacingComment();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [visible, isPlacingComment, handleCancelPlacingComment]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
@@ -716,6 +744,18 @@ export function CommentsSidebar({
|
||||
</Menu>
|
||||
</Group>
|
||||
)}
|
||||
{toggleCommentsSidebar && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={toggleCommentsSidebar}
|
||||
aria-label="Close comments sidebar"
|
||||
title={t("viewer.comments.close", "Close comments")}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Stack p="sm" gap="md">
|
||||
@@ -733,434 +773,517 @@ export function CommentsSidebar({
|
||||
"Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.",
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={handleAddComment}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="1rem" height="1rem" />
|
||||
}
|
||||
>
|
||||
{t("viewer.comments.addComment", "Add comment")}
|
||||
</Button>
|
||||
{isPlacingComment ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="xs"
|
||||
onClick={handleCancelPlacingComment}
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="touch-app-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"viewer.comments.placingHint",
|
||||
"Click a page to place… (cancel)",
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={handleAddComment}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="1rem" height="1rem" />
|
||||
}
|
||||
>
|
||||
{t("viewer.comments.addComment", "Add comment")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
pageNumbers.map((pageIndex) => {
|
||||
const entries = byPage[pageIndex] ?? [];
|
||||
const pageNum = pageIndex + 1;
|
||||
return (
|
||||
<Box key={pageIndex} mb="md">
|
||||
<Text size="sm" fw={700} mb={2}>
|
||||
{t("viewer.comments.pageLabel", "Page {{page}}", {
|
||||
page: pageNum,
|
||||
})}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{t("viewer.comments.nComments", "{{count}} comment(s)", {
|
||||
count: entries.length,
|
||||
})}
|
||||
</Text>
|
||||
<Box
|
||||
mb="xs"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="sm">
|
||||
{entries.map((entry) => {
|
||||
const ann = entry.annotation?.object;
|
||||
const id = ann?.id;
|
||||
if (!id) return null;
|
||||
const key = `${pageIndex}_${id}`;
|
||||
const replyKey = `${pageIndex}_${id}_reply`;
|
||||
const displayContent = getCommentDisplayContent(entry);
|
||||
const draft =
|
||||
draftContents[key] !== undefined
|
||||
? draftContents[key]
|
||||
: displayContent;
|
||||
const replyDraft = replyDrafts[replyKey] ?? "";
|
||||
const authorName = getAuthorName(ann, displayName);
|
||||
/** Only treat as "comment posted" when annotation actually has content (user clicked Send), not on every keystroke. */
|
||||
const hasMainContent =
|
||||
(displayContent ?? "").trim().length > 0;
|
||||
const isEditingMain = editingMainKey === key;
|
||||
<>
|
||||
{isPlacingComment ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="compact-xs"
|
||||
fullWidth
|
||||
onClick={handleCancelPlacingComment}
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="touch-app-rounded"
|
||||
width="0.9rem"
|
||||
height="0.9rem"
|
||||
/>
|
||||
}
|
||||
styles={{
|
||||
root: {
|
||||
justifyContent: "flex-start",
|
||||
paddingInline: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"viewer.comments.placingHint",
|
||||
"Click a page to place… (cancel)",
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
fullWidth
|
||||
onClick={handleAddComment}
|
||||
leftSection={
|
||||
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
styles={{
|
||||
root: {
|
||||
justifyContent: "flex-start",
|
||||
paddingInline: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("viewer.comments.addComment", "Add comment")}
|
||||
</Button>
|
||||
)}
|
||||
{pageNumbers.map((pageIndex) => {
|
||||
const entries = byPage[pageIndex] ?? [];
|
||||
const pageNum = pageIndex + 1;
|
||||
return (
|
||||
<Box key={pageIndex} mb="md">
|
||||
<Text size="sm" fw={700} mb={2}>
|
||||
{t("viewer.comments.pageLabel", "Page {{page}}", {
|
||||
page: pageNum,
|
||||
})}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{t("viewer.comments.nComments", "{{count}} comments", {
|
||||
count: entries.length,
|
||||
})}
|
||||
</Text>
|
||||
<Box
|
||||
mb="xs"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="sm">
|
||||
{entries.map((entry) => {
|
||||
const ann = entry.annotation?.object;
|
||||
const id = ann?.id;
|
||||
if (!id) return null;
|
||||
const key = `${pageIndex}_${id}`;
|
||||
const replyKey = `${pageIndex}_${id}_reply`;
|
||||
const displayContent = getCommentDisplayContent(entry);
|
||||
const draft =
|
||||
draftContents[key] !== undefined
|
||||
? draftContents[key]
|
||||
: displayContent;
|
||||
const replyDraft = replyDrafts[replyKey] ?? "";
|
||||
const authorName = getAuthorName(ann, displayName);
|
||||
/** Only treat as "comment posted" when annotation actually has content (user clicked Send), not on every keystroke. */
|
||||
const hasMainContent =
|
||||
(displayContent ?? "").trim().length > 0;
|
||||
const isEditingMain = editingMainKey === key;
|
||||
|
||||
const mainTimestamp = formatCommentDate(ann);
|
||||
const typeLabel = getAnnotationTypeLabel(ann, t);
|
||||
const mainTimestamp = formatCommentDate(ann);
|
||||
const typeLabel = getAnnotationTypeLabel(ann, t);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={key}
|
||||
data-comment-card={key}
|
||||
p="sm"
|
||||
style={{
|
||||
border: selectedAnnotationIds.has(id)
|
||||
? "1px solid var(--mantine-color-blue-3)"
|
||||
: "1px solid var(--border-subtle)",
|
||||
borderRadius: 8,
|
||||
backgroundColor: "var(--bg-raised)",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
mb="xs"
|
||||
return (
|
||||
<Box
|
||||
key={key}
|
||||
data-comment-card={key}
|
||||
p="sm"
|
||||
style={{
|
||||
border: selectedAnnotationIds.has(id)
|
||||
? "1px solid var(--mantine-color-blue-3)"
|
||||
: "1px solid var(--border-subtle)",
|
||||
borderRadius: 8,
|
||||
backgroundColor: "var(--bg-raised)",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
mb="xs"
|
||||
>
|
||||
<AnnotationTypeIcon ann={ann} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{authorName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{typeLabel}
|
||||
{mainTimestamp ? ` · ${mainTimestamp}` : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group
|
||||
gap={2}
|
||||
wrap="nowrap"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"viewer.comments.locateAnnotation",
|
||||
"Locate in document",
|
||||
)}
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
>
|
||||
<AnnotationTypeIcon ann={ann} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{authorName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{typeLabel}
|
||||
{mainTimestamp ? ` · ${mainTimestamp}` : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group
|
||||
gap={2}
|
||||
wrap="nowrap"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={() =>
|
||||
handleLocateAnnotation(pageIndex, ann)
|
||||
}
|
||||
>
|
||||
<VisibilityIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu.Target>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"viewer.comments.moreActions",
|
||||
"More actions",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
>
|
||||
<MoreHorizIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<EditIcon style={{ fontSize: 18 }} />
|
||||
}
|
||||
onClick={() => setEditingMainKey(key)}
|
||||
>
|
||||
{t("annotation.editText", "Edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<DeleteIcon style={{ fontSize: 18 }} />
|
||||
}
|
||||
color="red"
|
||||
onClick={() =>
|
||||
handleDeleteClick(pageIndex, id, ann)
|
||||
}
|
||||
>
|
||||
{t("annotation.delete", "Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!hasMainContent || isEditingMain ? (
|
||||
<>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
"viewer.comments.addCommentPlaceholder",
|
||||
"Add comment...",
|
||||
)}
|
||||
minRows={2}
|
||||
autosize
|
||||
value={draft ?? ""}
|
||||
onChange={(e) => {
|
||||
const v =
|
||||
(e?.currentTarget ?? e?.target)?.value ??
|
||||
"";
|
||||
setDraftContents((prev) => ({
|
||||
...prev,
|
||||
[key]: v,
|
||||
}));
|
||||
if (isEditingMain) {
|
||||
handleContentsChange(pageIndex, id, v);
|
||||
}
|
||||
}}
|
||||
styles={{ root: { width: "100%" } }}
|
||||
mb="xs"
|
||||
/>
|
||||
<Group gap={4} wrap="nowrap" justify="flex-end">
|
||||
<Tooltip
|
||||
label={t(
|
||||
"viewer.comments.addComment",
|
||||
"Add comment",
|
||||
"viewer.comments.locateAnnotation",
|
||||
"Locate in document",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="blue"
|
||||
onClick={() => {
|
||||
handleSendMainComment(
|
||||
pageIndex,
|
||||
id,
|
||||
draft ?? "",
|
||||
);
|
||||
setEditingMainKey(null);
|
||||
}}
|
||||
disabled={!(draft ?? "").trim()}
|
||||
color="gray"
|
||||
onClick={() =>
|
||||
handleLocateAnnotation(pageIndex, ann)
|
||||
}
|
||||
>
|
||||
<CheckIcon
|
||||
style={{ fontSize: 18, color: "white" }}
|
||||
/>
|
||||
<VisibilityIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
size="sm"
|
||||
mb="sm"
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{displayContent}
|
||||
</Text>
|
||||
|
||||
{entry.replies?.length ? (
|
||||
<Stack gap="sm" mb="sm">
|
||||
{entry.replies.map((r) => {
|
||||
const rObj = r?.object;
|
||||
const rId = rObj?.id;
|
||||
if (!rId) return null;
|
||||
const rAuthor = getAuthorName(
|
||||
rObj,
|
||||
displayName,
|
||||
);
|
||||
const rTimestamp = formatCommentDate(rObj);
|
||||
const replyEditKey = `${pageIndex}_${id}_${rId}`;
|
||||
const isEditingReply =
|
||||
editingReplyKey === replyEditKey;
|
||||
const canEditReply =
|
||||
isReplyAuthoredByCurrentUser(
|
||||
rObj,
|
||||
displayName,
|
||||
);
|
||||
const replyBody =
|
||||
replyEditDrafts[replyEditKey] !==
|
||||
undefined
|
||||
? replyEditDrafts[replyEditKey]
|
||||
: (rObj?.contents ?? "");
|
||||
return (
|
||||
<Box
|
||||
key={rId}
|
||||
pl="xs"
|
||||
style={{
|
||||
borderLeft:
|
||||
"2px solid var(--mantine-color-blue-3)",
|
||||
}}
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu.Target>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"viewer.comments.moreActions",
|
||||
"More actions",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
gap={4}
|
||||
mb={2}
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
{rAuthor}
|
||||
</Text>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
align="center"
|
||||
>
|
||||
{canEditReply &&
|
||||
!isEditingReply ? (
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingReplyKey(
|
||||
replyEditKey,
|
||||
);
|
||||
setReplyEditDrafts(() => ({
|
||||
[replyEditKey]: String(
|
||||
rObj?.contents ?? "",
|
||||
),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="blue">
|
||||
{t(
|
||||
"annotation.editText",
|
||||
"Edit",
|
||||
)}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
) : null}
|
||||
{rTimestamp ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{rTimestamp}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
{isEditingReply ? (
|
||||
<>
|
||||
<Textarea
|
||||
minRows={2}
|
||||
autosize
|
||||
value={replyBody}
|
||||
onChange={(e) => {
|
||||
const v =
|
||||
(
|
||||
e?.currentTarget ??
|
||||
e?.target
|
||||
)?.value ?? "";
|
||||
setReplyEditDrafts((p) => ({
|
||||
...p,
|
||||
[replyEditKey]: v,
|
||||
}));
|
||||
}}
|
||||
styles={{
|
||||
root: { width: "100%" },
|
||||
}}
|
||||
mb="xs"
|
||||
/>
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
justify="flex-end"
|
||||
>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"viewer.comments.saveReply",
|
||||
"Save reply",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
size="sm"
|
||||
color="blue"
|
||||
onClick={() =>
|
||||
handleSaveReplyEdit(
|
||||
replyEditKey,
|
||||
pageIndex,
|
||||
rId,
|
||||
replyBody,
|
||||
)
|
||||
}
|
||||
disabled={!replyBody.trim()}
|
||||
>
|
||||
<CheckIcon
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{rObj?.contents ?? ""}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : null}
|
||||
<MoreHorizIcon
|
||||
style={{ fontSize: 20 }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<EditIcon style={{ fontSize: 18 }} />
|
||||
}
|
||||
onClick={() => setEditingMainKey(key)}
|
||||
>
|
||||
{t("annotation.editText", "Edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<DeleteIcon style={{ fontSize: 18 }} />
|
||||
}
|
||||
color="red"
|
||||
onClick={() =>
|
||||
handleDeleteClick(pageIndex, id, ann)
|
||||
}
|
||||
>
|
||||
{t("annotation.delete", "Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" align="flex-end">
|
||||
<TextInput
|
||||
{!hasMainContent || isEditingMain ? (
|
||||
<>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
"viewer.comments.addReplyPlaceholder",
|
||||
"Add reply...",
|
||||
"viewer.comments.addCommentPlaceholder",
|
||||
"Add comment...",
|
||||
)}
|
||||
size="xs"
|
||||
value={replyDraft}
|
||||
minRows={2}
|
||||
autosize
|
||||
value={draft ?? ""}
|
||||
onChange={(e) => {
|
||||
const v =
|
||||
(e?.currentTarget ?? e?.target)?.value ??
|
||||
"";
|
||||
setReplyDrafts((p) => ({
|
||||
...p,
|
||||
[replyKey]: v,
|
||||
setDraftContents((prev) => ({
|
||||
...prev,
|
||||
[key]: v,
|
||||
}));
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
styles={{
|
||||
input: {
|
||||
borderColor:
|
||||
"var(--mantine-color-blue-3)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"viewer.comments.addComment",
|
||||
"Add comment",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
size="md"
|
||||
color="blue"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"var(--mantine-color-blue-6)",
|
||||
}}
|
||||
onClick={() =>
|
||||
handleSendReply(pageIndex, id, ann?.rect)
|
||||
if (isEditingMain) {
|
||||
handleContentsChange(pageIndex, id, v);
|
||||
}
|
||||
disabled={!replyDraft.trim()}
|
||||
}}
|
||||
styles={{ root: { width: "100%" } }}
|
||||
mb="xs"
|
||||
/>
|
||||
<Group gap={4} wrap="nowrap" justify="flex-end">
|
||||
<Tooltip
|
||||
label={t(
|
||||
"viewer.comments.addComment",
|
||||
"Add comment",
|
||||
)}
|
||||
>
|
||||
<CheckIcon
|
||||
style={{ fontSize: 20, color: "white" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
size="sm"
|
||||
color="blue"
|
||||
onClick={() => {
|
||||
handleSendMainComment(
|
||||
pageIndex,
|
||||
id,
|
||||
draft ?? "",
|
||||
);
|
||||
setEditingMainKey(null);
|
||||
}}
|
||||
disabled={!(draft ?? "").trim()}
|
||||
>
|
||||
<CheckIcon
|
||||
style={{ fontSize: 18, color: "white" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
size="sm"
|
||||
mb="sm"
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{displayContent}
|
||||
</Text>
|
||||
|
||||
{entry.replies?.length ? (
|
||||
<Stack gap="sm" mb="sm">
|
||||
{entry.replies.map((r) => {
|
||||
const rObj = r?.object;
|
||||
const rId = rObj?.id;
|
||||
if (!rId) return null;
|
||||
const rAuthor = getAuthorName(
|
||||
rObj,
|
||||
displayName,
|
||||
);
|
||||
const rTimestamp =
|
||||
formatCommentDate(rObj);
|
||||
const replyEditKey = `${pageIndex}_${id}_${rId}`;
|
||||
const isEditingReply =
|
||||
editingReplyKey === replyEditKey;
|
||||
const canEditReply =
|
||||
isReplyAuthoredByCurrentUser(
|
||||
rObj,
|
||||
displayName,
|
||||
);
|
||||
const replyBody =
|
||||
replyEditDrafts[replyEditKey] !==
|
||||
undefined
|
||||
? replyEditDrafts[replyEditKey]
|
||||
: (rObj?.contents ?? "");
|
||||
return (
|
||||
<Box
|
||||
key={rId}
|
||||
pl="xs"
|
||||
style={{
|
||||
borderLeft:
|
||||
"2px solid var(--mantine-color-blue-3)",
|
||||
}}
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
gap={4}
|
||||
mb={2}
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
{rAuthor}
|
||||
</Text>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
align="center"
|
||||
>
|
||||
{canEditReply &&
|
||||
!isEditingReply ? (
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingReplyKey(
|
||||
replyEditKey,
|
||||
);
|
||||
setReplyEditDrafts(
|
||||
() => ({
|
||||
[replyEditKey]:
|
||||
String(
|
||||
rObj?.contents ??
|
||||
"",
|
||||
),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="blue">
|
||||
{t(
|
||||
"annotation.editText",
|
||||
"Edit",
|
||||
)}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
) : null}
|
||||
{rTimestamp ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{rTimestamp}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
{isEditingReply ? (
|
||||
<>
|
||||
<Textarea
|
||||
minRows={2}
|
||||
autosize
|
||||
value={replyBody}
|
||||
onChange={(e) => {
|
||||
const v =
|
||||
(
|
||||
e?.currentTarget ??
|
||||
e?.target
|
||||
)?.value ?? "";
|
||||
setReplyEditDrafts((p) => ({
|
||||
...p,
|
||||
[replyEditKey]: v,
|
||||
}));
|
||||
}}
|
||||
styles={{
|
||||
root: { width: "100%" },
|
||||
}}
|
||||
mb="xs"
|
||||
/>
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
justify="flex-end"
|
||||
>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"viewer.comments.saveReply",
|
||||
"Save reply",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
size="sm"
|
||||
color="blue"
|
||||
onClick={() =>
|
||||
handleSaveReplyEdit(
|
||||
replyEditKey,
|
||||
pageIndex,
|
||||
rId,
|
||||
replyBody,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
!replyBody.trim()
|
||||
}
|
||||
>
|
||||
<CheckIcon
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: "white",
|
||||
}}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{rObj?.contents ?? ""}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Group gap="xs" wrap="nowrap" align="flex-end">
|
||||
<TextInput
|
||||
placeholder={t(
|
||||
"viewer.comments.addReplyPlaceholder",
|
||||
"Add reply...",
|
||||
)}
|
||||
size="xs"
|
||||
value={replyDraft}
|
||||
onChange={(e) => {
|
||||
const v =
|
||||
(e?.currentTarget ?? e?.target)
|
||||
?.value ?? "";
|
||||
setReplyDrafts((p) => ({
|
||||
...p,
|
||||
[replyKey]: v,
|
||||
}));
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
styles={{
|
||||
input: {
|
||||
borderColor:
|
||||
"var(--mantine-color-blue-3)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"viewer.comments.addComment",
|
||||
"Add comment",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
size="md"
|
||||
color="blue"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"var(--mantine-color-blue-6)",
|
||||
}}
|
||||
onClick={() =>
|
||||
handleSendReply(
|
||||
pageIndex,
|
||||
id,
|
||||
ann?.rect,
|
||||
)
|
||||
}
|
||||
disabled={!replyDraft.trim()}
|
||||
>
|
||||
<CheckIcon
|
||||
style={{ fontSize: 20, color: "white" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import LayersIcon from "@mui/icons-material/Layers";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import "@app/components/viewer/SidebarBase.css";
|
||||
import "@app/components/viewer/LayerSidebar.css";
|
||||
import {
|
||||
@@ -49,6 +51,7 @@ export function LayerSidebar({
|
||||
onApplyLayers,
|
||||
onLayersDetected,
|
||||
}: LayerSidebarProps) {
|
||||
const { toggleLayerSidebar } = useViewer();
|
||||
const [layers, setLayers] = useState<LayerInfo[]>([]);
|
||||
const [visibility, setVisibility] = useState<Record<string, boolean>>({});
|
||||
const [status, setStatus] = useState<LoadStatus>("idle");
|
||||
@@ -337,30 +340,42 @@ export function LayerSidebar({
|
||||
{isApplying && <Loader size="xs" type="dots" />}
|
||||
</div>
|
||||
|
||||
{status === "ready" && leafIds.length > 0 && (
|
||||
<div className="layer-sidebar__header-actions">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={showAll}
|
||||
disabled={allVisible || isApplying}
|
||||
aria-label="Show all layers"
|
||||
title="Show all"
|
||||
>
|
||||
<VisibilityIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={hideAll}
|
||||
disabled={allHidden || isApplying}
|
||||
aria-label="Hide all layers"
|
||||
title="Hide all"
|
||||
>
|
||||
<VisibilityOffIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
)}
|
||||
<div className="layer-sidebar__header-actions">
|
||||
{status === "ready" && leafIds.length > 0 && (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={showAll}
|
||||
disabled={allVisible || isApplying}
|
||||
aria-label="Show all layers"
|
||||
title="Show all"
|
||||
>
|
||||
<VisibilityIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={hideAll}
|
||||
disabled={allHidden || isApplying}
|
||||
aria-label="Hide all layers"
|
||||
title="Hide all"
|
||||
>
|
||||
<VisibilityOffIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={toggleLayerSidebar}
|
||||
aria-label="Close layers sidebar"
|
||||
title="Close layers"
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Box, ScrollArea } from "@mantine/core";
|
||||
import { Box, ScrollArea, Text, ActionIcon } from "@mantine/core";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import "@app/components/viewer/SidebarBase.css";
|
||||
|
||||
interface ThumbnailSidebarProps {
|
||||
visible: boolean;
|
||||
@@ -11,7 +14,7 @@ interface ThumbnailSidebarProps {
|
||||
|
||||
export function ThumbnailSidebar({
|
||||
visible,
|
||||
onToggle: _onToggle,
|
||||
onToggle,
|
||||
activeFileId,
|
||||
}: ThumbnailSidebarProps) {
|
||||
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
|
||||
@@ -153,20 +156,42 @@ export function ThumbnailSidebar({
|
||||
{/* Thumbnail Sidebar */}
|
||||
{visible && (
|
||||
<Box
|
||||
className="sidebar-base"
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "15rem",
|
||||
backgroundColor: "var(--bg-surface)",
|
||||
borderLeft: "1px solid var(--border-subtle)",
|
||||
zIndex: 998,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "-2px 0 8px rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-base__header">
|
||||
<div className="sidebar-base__header-title">
|
||||
<span className="sidebar-base__header-icon">
|
||||
<ViewListIcon fontSize="small" />
|
||||
</span>
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
tt="uppercase"
|
||||
lts={0.5}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Pages
|
||||
</Text>
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={onToggle}
|
||||
aria-label="Close thumbnails sidebar"
|
||||
title="Close thumbnails"
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
{/* Thumbnails Container */}
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Box p="sm">
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import React, { createContext, useContext, ReactNode, useRef } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
ReactNode,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import type {
|
||||
AnnotationAPI,
|
||||
AnnotationToolId,
|
||||
@@ -10,6 +17,16 @@ interface AnnotationContextValue {
|
||||
activateAnnotationToolRef: React.RefObject<
|
||||
((toolId: AnnotationToolId) => void) | null
|
||||
>;
|
||||
/**
|
||||
* Observable copy of the annotation panel's currently armed tool.
|
||||
* Annotate.tsx mirrors its local activeTool state here so the
|
||||
* CommentsSidebar (and other components outside the panel) can react
|
||||
* to "textComment is armed" without coupling to the panel's internals.
|
||||
* Null when no annotation tool is armed (panel not mounted or set to
|
||||
* "select").
|
||||
*/
|
||||
activeAnnotationToolId: AnnotationToolId | null;
|
||||
setActiveAnnotationToolId: (id: AnnotationToolId | null) => void;
|
||||
}
|
||||
|
||||
const AnnotationContext = createContext<AnnotationContextValue | undefined>(
|
||||
@@ -23,10 +40,20 @@ export const AnnotationProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const activateAnnotationToolRef = useRef<
|
||||
((toolId: AnnotationToolId) => void) | null
|
||||
>(null);
|
||||
const [activeAnnotationToolId, setActiveAnnotationToolIdState] =
|
||||
useState<AnnotationToolId | null>(null);
|
||||
const setActiveAnnotationToolId = useCallback(
|
||||
(id: AnnotationToolId | null) => {
|
||||
setActiveAnnotationToolIdState(id);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value: AnnotationContextValue = {
|
||||
annotationApiRef,
|
||||
activateAnnotationToolRef,
|
||||
activeAnnotationToolId,
|
||||
setActiveAnnotationToolId,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,10 @@ import React, {
|
||||
import { useNavigation } from "@app/contexts/NavigationContext";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import { isStirlingFile } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { enforceExportPolicies } from "@app/services/policyExport";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { alert } from "@app/components/toast";
|
||||
import {
|
||||
preferencesService,
|
||||
type PdfRenderMode,
|
||||
@@ -216,6 +220,7 @@ interface ViewerProviderProps {
|
||||
}
|
||||
|
||||
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
const { t } = useTranslation();
|
||||
// UI state - only state directly managed by this context
|
||||
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] =
|
||||
useState(false);
|
||||
@@ -537,6 +542,45 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
triggerImmediateZoomUpdate,
|
||||
});
|
||||
|
||||
// Printing is an exit path, so a "run on export" policy must enforce here too.
|
||||
// Enforce the current file through the same path export uses: when a policy
|
||||
// rewrites it, that path versions the in-editor file to the enforced output
|
||||
// and marks it enforced, so a follow-up print of the unedited result prints
|
||||
// it as-is instead of re-running the (non-idempotent) policy. Ask the user to
|
||||
// review the updated doc before printing again, rather than printing bytes
|
||||
// they haven't seen. With no active export policy this is a no-op and print
|
||||
// runs straight away.
|
||||
const printWithPolicy = useCallback(async () => {
|
||||
const file = activeFileId
|
||||
? selectors.getFiles([activeFileId as FileId])[0]
|
||||
: undefined;
|
||||
if (!activeFileId || !file) {
|
||||
printActions.print();
|
||||
return;
|
||||
}
|
||||
const [enforced] = await enforceExportPolicies(
|
||||
[file],
|
||||
[activeFileId],
|
||||
"print",
|
||||
);
|
||||
// Original file back means no policy rewrote it (no active policy, already
|
||||
// enforced, or graceful failure fallback) — nothing new to review, print it.
|
||||
if (!enforced || enforced === file) {
|
||||
printActions.print();
|
||||
return;
|
||||
}
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t("policies.enforcement.printPolicyAppliedTitle"),
|
||||
body: t("policies.enforcement.printPolicyAppliedBody"),
|
||||
});
|
||||
}, [activeFileId, selectors, printActions]);
|
||||
|
||||
const enforcedPrintActions = useMemo<PrintActions>(
|
||||
() => ({ print: printWithPolicy }),
|
||||
[printWithPolicy],
|
||||
);
|
||||
|
||||
const value: ViewerContextType = {
|
||||
// UI state
|
||||
isThumbnailSidebarVisible,
|
||||
@@ -610,7 +654,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
exportActions,
|
||||
bookmarkActions,
|
||||
attachmentActions,
|
||||
printActions,
|
||||
printActions: enforcedPrintActions,
|
||||
|
||||
// Bridge registration
|
||||
registerBridge,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
export async function enforceExportPolicies(
|
||||
files: File[],
|
||||
_fileIds?: (string | undefined)[],
|
||||
_trigger?: "export" | "print" | "convert" | "input",
|
||||
): Promise<File[]> {
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import { loginAndSetup } from "@app/tests/helpers/login";
|
||||
import { runToolAndWaitForReview } from "@app/tests/helpers/ui-helpers";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import {
|
||||
PDFDocument,
|
||||
PDFName,
|
||||
PDFDict,
|
||||
PDFString,
|
||||
PDFHexString,
|
||||
} from "@cantoo/pdf-lib";
|
||||
|
||||
/**
|
||||
* End-to-end validation of the new "Add attachment" and "Add bookmark"
|
||||
* buttons on the viewer's attachment / bookmark sidebars.
|
||||
*
|
||||
* Each test:
|
||||
* 1. Logs in and uploads a sample PDF via the Read tool's viewer.
|
||||
* 2. Opens the relevant sidebar.
|
||||
* 3. Confirms the empty-state Add button is visible.
|
||||
* 4. Clicks it - URL must switch to the corresponding tool page.
|
||||
* 5. Completes the tool's flow (pick a file to attach / type a
|
||||
* bookmark title).
|
||||
* 6. Runs the tool and intercepts the backend response.
|
||||
* 7. Loads the produced PDF with pdf-lib and verifies it actually
|
||||
* contains the new attachment / new bookmark.
|
||||
*
|
||||
* Requires a real Spring Boot backend on :8080 - registered under the
|
||||
* `live` Playwright project. The `live-setup` project bootstraps the
|
||||
* admin user before this runs.
|
||||
*/
|
||||
|
||||
function fixture(filename: string): string {
|
||||
const candidates = [
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
"src",
|
||||
"core",
|
||||
"tests",
|
||||
"test-fixtures",
|
||||
filename,
|
||||
),
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
"frontend",
|
||||
"src",
|
||||
"core",
|
||||
"tests",
|
||||
"test-fixtures",
|
||||
filename,
|
||||
),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
throw new Error(
|
||||
`Test fixture not found: ${filename} (tried: ${candidates.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function openSamplePdfInViewer(page: import("@playwright/test").Page) {
|
||||
await page.goto("/read");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page
|
||||
.locator('[data-testid="file-input"]')
|
||||
.first()
|
||||
.setInputFiles(fixture("sample.pdf"));
|
||||
// Page indicator confirms the embedded viewer has the document loaded.
|
||||
await expect(page.getByText(/\/\s*1/).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Viewer sidebar add buttons - real PDF round-trip", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAndSetup(page);
|
||||
});
|
||||
|
||||
test("Add attachment from viewer sidebar embeds the chosen file in the produced PDF", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openSamplePdfInViewer(page);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /Toggle Attachments/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const addBtn = page.getByRole("button", { name: /^Add attachment$/i });
|
||||
await expect(addBtn).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await addBtn.click();
|
||||
await expect(page).toHaveURL(/\/add-attachments$/, { timeout: 10_000 });
|
||||
|
||||
// Hidden picker the AddAttachments tool exposes. Attach a small known
|
||||
// file - reuse the sample fixture as the attachment payload so we can
|
||||
// assert on its filename below.
|
||||
const attachmentName = "sample.pdf";
|
||||
await page
|
||||
.locator("#attachments-input")
|
||||
.setInputFiles(fixture(attachmentName));
|
||||
|
||||
// Capture the backend response so we can inspect the produced PDF.
|
||||
const responsePromise = page.waitForResponse(
|
||||
(r) =>
|
||||
/\/api\/v1\/(general|misc)\/add-attachments$/.test(r.url()) &&
|
||||
r.status() === 200,
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
|
||||
await runToolAndWaitForReview(page);
|
||||
|
||||
const response = await responsePromise;
|
||||
const pdfBytes = await response.body();
|
||||
|
||||
// Sanity: response is a PDF (starts with %PDF-)
|
||||
expect(pdfBytes.slice(0, 5).toString()).toBe("%PDF-");
|
||||
|
||||
// Verify the produced PDF actually contains the attachment.
|
||||
const doc = await PDFDocument.load(pdfBytes, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
const attachments = doc.getAttachments();
|
||||
expect(attachments.length).toBeGreaterThan(0);
|
||||
expect(attachments.map((a) => a.name)).toContain(attachmentName);
|
||||
});
|
||||
|
||||
test("Add bookmark from viewer sidebar adds the bookmark to the produced PDF outline", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openSamplePdfInViewer(page);
|
||||
const viewerUrl = page.url();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /Toggle Bookmarks/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const addBtn = page.getByRole("button", { name: /^Add bookmark$/i });
|
||||
await expect(addBtn).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await addBtn.click();
|
||||
|
||||
// Stays in the viewer - URL doesn't change to /edit-table-of-contents.
|
||||
expect(page.url()).toBe(viewerUrl);
|
||||
|
||||
// Inline form appears inside the sidebar with title + page inputs.
|
||||
const form = page.locator('[data-testid="bookmark-add-form"]');
|
||||
await expect(form).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const BOOKMARK_TITLE = `Playwright test bookmark ${Date.now()}`;
|
||||
await form
|
||||
.getByRole("textbox", { name: /Bookmark title/i })
|
||||
.fill(BOOKMARK_TITLE);
|
||||
|
||||
// Capture the backend POST the inline Save kicks off.
|
||||
const responsePromise = page.waitForResponse(
|
||||
(r) =>
|
||||
/\/api\/v1\/general\/edit-table-of-contents$/.test(r.url()) &&
|
||||
r.status() === 200,
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
|
||||
await form.getByRole("button", { name: /^Save$/i }).click();
|
||||
|
||||
const response = await responsePromise;
|
||||
const pdfBytes = await response.body();
|
||||
|
||||
// Form should close on success and the user should still be in the
|
||||
// viewer (no tool navigation).
|
||||
await expect(form).not.toBeVisible({ timeout: 10_000 });
|
||||
expect(page.url()).toBe(viewerUrl);
|
||||
|
||||
expect(pdfBytes.slice(0, 5).toString()).toBe("%PDF-");
|
||||
|
||||
// Verify the produced PDF has an /Outlines entry in the catalog and
|
||||
// the bookmark title we set is present in the document.
|
||||
const doc = await PDFDocument.load(pdfBytes, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
const outlinesRef = doc.catalog.get(PDFName.of("Outlines"));
|
||||
expect(outlinesRef, "PDF catalog must have /Outlines entry").toBeDefined();
|
||||
|
||||
// Walk the outline tree and collect titles.
|
||||
const outlinesDict = doc.context.lookup(outlinesRef, PDFDict);
|
||||
const collectTitles = (
|
||||
dictRef: ReturnType<PDFDict["get"]> | undefined,
|
||||
acc: string[],
|
||||
): string[] => {
|
||||
if (!dictRef) return acc;
|
||||
const node = doc.context.lookupMaybe(dictRef, PDFDict);
|
||||
if (!node) return acc;
|
||||
const title = node.get(PDFName.of("Title"));
|
||||
if (title instanceof PDFString || title instanceof PDFHexString) {
|
||||
try {
|
||||
acc.push(title.decodeText());
|
||||
} catch {
|
||||
// Title couldn't decode - fall back to asString
|
||||
acc.push(title.asString());
|
||||
}
|
||||
}
|
||||
collectTitles(node.get(PDFName.of("First")), acc);
|
||||
collectTitles(node.get(PDFName.of("Next")), acc);
|
||||
return acc;
|
||||
};
|
||||
|
||||
const titles = collectTitles(outlinesDict.get(PDFName.of("First")), []);
|
||||
expect(
|
||||
titles,
|
||||
`expected outline titles to include "${BOOKMARK_TITLE}", got ${JSON.stringify(titles)}`,
|
||||
).toContain(BOOKMARK_TITLE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Verifies the new "Add attachment" and "Add bookmark" buttons on the
|
||||
* viewer's attachment + bookmark sidebars.
|
||||
*
|
||||
* After the UX refactor:
|
||||
* - Attachment sidebar's Add button still navigates to the
|
||||
* AddAttachments tool, BUT also closes the attachment sidebar so
|
||||
* the user doesn't see two stacked side panels.
|
||||
* - Bookmark sidebar's Add button opens an inline form (title + page,
|
||||
* defaulting to the current page) inside the sidebar - the user
|
||||
* never leaves the viewer. Submitting POSTs to the backend (not
|
||||
* covered here - see the live spec for that).
|
||||
*
|
||||
* Backend-free spec.
|
||||
*/
|
||||
|
||||
const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
|
||||
|
||||
async function openViewerWithSample(page: import("@playwright/test").Page) {
|
||||
await page.goto("/read");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page
|
||||
.locator('[data-testid="file-input"]')
|
||||
.first()
|
||||
.setInputFiles(SAMPLE_PDF);
|
||||
await expect(page.getByText(/\/\s*1/).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Viewer sidebar: Add attachment / Add bookmark buttons", () => {
|
||||
test("Attachment sidebar Add button navigates to tool and closes the sidebar", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openViewerWithSample(page);
|
||||
|
||||
const attachmentsToggle = page
|
||||
.getByRole("button", { name: /Toggle Attachments/i })
|
||||
.first();
|
||||
await attachmentsToggle.click();
|
||||
|
||||
// Sidebar header shows up
|
||||
const sidebarTitle = page.getByText(/^Attachments$/i).first();
|
||||
await expect(sidebarTitle).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const addBtn = page.getByRole("button", { name: /^Add attachment$/i });
|
||||
await expect(addBtn).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await addBtn.click();
|
||||
|
||||
// URL syncs to /add-attachments
|
||||
await expect(page).toHaveURL(/\/add-attachments$/, { timeout: 10_000 });
|
||||
// Sidebar should have auto-closed (no stacked panels)
|
||||
await expect(sidebarTitle).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("Bookmark sidebar Add button opens an inline form (no navigation away from viewer)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openViewerWithSample(page);
|
||||
|
||||
const initialUrl = page.url();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /Toggle Bookmarks/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(page.getByText(/^Bookmarks$/i).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const addBtn = page.getByRole("button", { name: /^Add bookmark$/i });
|
||||
await expect(addBtn).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await addBtn.click();
|
||||
|
||||
// Inline form appears (title + page inputs + Save/Cancel) - the
|
||||
// form is identifiable by its data-testid so we don't depend on
|
||||
// matching label text fragility.
|
||||
const form = page.locator('[data-testid="bookmark-add-form"]');
|
||||
await expect(form).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
form.getByRole("textbox", { name: /Bookmark title/i }),
|
||||
).toBeVisible();
|
||||
await expect(form.getByRole("button", { name: /^Save$/i })).toBeVisible();
|
||||
await expect(form.getByRole("button", { name: /^Cancel$/i })).toBeVisible();
|
||||
|
||||
// Crucially, the URL did NOT change to the tool route - user stays
|
||||
// in the viewer.
|
||||
expect(page.url()).toBe(initialUrl);
|
||||
|
||||
// Cancel closes the form (no backend hit)
|
||||
await form.getByRole("button", { name: /^Cancel$/i }).click();
|
||||
await expect(form).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("Bookmark Save POSTs to backend, closes the form, and stays in viewer", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Stub the edit-table-of-contents endpoint with a tiny valid PDF
|
||||
// blob so the Save flow can complete without the live backend.
|
||||
// The minimal PDF below is just enough that PDFDocument loaders
|
||||
// accept it as application/pdf - we don't need real bookmarks in
|
||||
// the response for this assertion. We only check that:
|
||||
// (1) the click actually issues the POST (regression for the
|
||||
// silent-fallback bug where activeFileId was null on a fresh
|
||||
// upload and Save quietly routed to the full editor); and
|
||||
// (2) the form closes and the viewer URL doesn't change.
|
||||
const MINIMAL_PDF =
|
||||
"%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000010 00000 n\n0000000053 00000 n\n0000000098 00000 n\ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n149\n%%EOF\n";
|
||||
let postSeen = false;
|
||||
await page.route(
|
||||
"**/api/v1/general/edit-table-of-contents",
|
||||
async (route) => {
|
||||
postSeen = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/pdf",
|
||||
body: Buffer.from(MINIMAL_PDF, "binary"),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await openViewerWithSample(page);
|
||||
const viewerUrl = page.url();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /Toggle Bookmarks/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole("button", { name: /^Add bookmark$/i }).click();
|
||||
|
||||
const form = page.locator('[data-testid="bookmark-add-form"]');
|
||||
await expect(form).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await form
|
||||
.getByRole("textbox", { name: /Bookmark title/i })
|
||||
.fill("Stub bookmark");
|
||||
await form.getByRole("button", { name: /^Save$/i }).click();
|
||||
|
||||
// The POST must actually have fired. The earlier silent-fallback
|
||||
// bug would have routed to /edit-table-of-contents without ever
|
||||
// calling the API.
|
||||
await expect.poll(() => postSeen, { timeout: 15_000 }).toBe(true);
|
||||
|
||||
// Form closes on success, viewer URL unchanged (no tool nav).
|
||||
await expect(form).not.toBeVisible({ timeout: 10_000 });
|
||||
expect(page.url()).toBe(viewerUrl);
|
||||
});
|
||||
|
||||
test("Each sidebar header has a close (X) button that dismisses it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openViewerWithSample(page);
|
||||
|
||||
// Visibility of the close button itself is the stable signal:
|
||||
// it's rendered only when the sidebar is mounted.
|
||||
//
|
||||
// Mantine 8 ActionIcon has a known pointer-event sequence quirk
|
||||
// where Playwright's synthesized click() can leave the button in
|
||||
// mantine-active without firing the React onClick handler. We
|
||||
// sidestep it by dispatching mousedown + mouseup + click explicitly,
|
||||
// which mirrors a real user's pointer interaction in browser.
|
||||
const closeViaButton = async (name: RegExp) => {
|
||||
const btn = page.getByRole("button", { name });
|
||||
await expect(btn).toBeVisible({ timeout: 10_000 });
|
||||
await btn.dispatchEvent("mousedown");
|
||||
await btn.dispatchEvent("mouseup");
|
||||
await btn.dispatchEvent("click");
|
||||
await expect(btn).not.toBeVisible({ timeout: 5_000 });
|
||||
};
|
||||
|
||||
// Bookmark sidebar
|
||||
await page
|
||||
.getByRole("button", { name: /Toggle Bookmarks/i })
|
||||
.first()
|
||||
.click();
|
||||
await closeViaButton(/Close bookmarks sidebar/i);
|
||||
|
||||
// Attachment sidebar
|
||||
await page
|
||||
.getByRole("button", { name: /Toggle Attachments/i })
|
||||
.first()
|
||||
.click();
|
||||
await closeViaButton(/Close attachments sidebar/i);
|
||||
|
||||
// Thumbnail sidebar
|
||||
await page
|
||||
.getByRole("button", { name: /Toggle Sidebar/i })
|
||||
.first()
|
||||
.click();
|
||||
await closeViaButton(/Close thumbnails sidebar/i);
|
||||
});
|
||||
});
|
||||
@@ -89,7 +89,8 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
placementPreviewSize,
|
||||
setPlacementPreviewSize,
|
||||
} = useSignature();
|
||||
const { activateAnnotationToolRef } = useAnnotationContext();
|
||||
const { activateAnnotationToolRef, setActiveAnnotationToolId } =
|
||||
useAnnotationContext();
|
||||
const viewerContext = useContext(ViewerContext);
|
||||
const viewerContextRef = useRef(viewerContext);
|
||||
useEffect(() => {
|
||||
@@ -141,7 +142,21 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
activeToolRef.current = activeTool;
|
||||
}, [activeTool]);
|
||||
// Mirror the panel's armed tool to AnnotationContext so callers
|
||||
// outside the panel (CommentsSidebar's Add Comment hint) can react
|
||||
// without coupling to this component's internals. "select" means
|
||||
// nothing is armed - publish null so consumers can branch cheaply.
|
||||
setActiveAnnotationToolId(activeTool === "select" ? null : activeTool);
|
||||
}, [activeTool, setActiveAnnotationToolId]);
|
||||
|
||||
// Make sure we clear the published tool when the annotation panel
|
||||
// unmounts (user navigated away from /annotate). Otherwise the
|
||||
// sidebar would think textComment is still armed.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setActiveAnnotationToolId(null);
|
||||
};
|
||||
}, [setActiveAnnotationToolId]);
|
||||
|
||||
// CSS to PDF size conversion accounting for zoom
|
||||
const cssToPdfSize = useCallback(
|
||||
|
||||
@@ -25,6 +25,7 @@ import DownloadIcon from "@mui/icons-material/Download";
|
||||
import SaveIcon from "@mui/icons-material/Save";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import { useFormFill } from "@app/tools/formFill/FormFillContext";
|
||||
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
|
||||
|
||||
interface FormSaveBarProps {
|
||||
/** The current file being viewed */
|
||||
@@ -77,15 +78,12 @@ export function FormSaveBar({
|
||||
setSaving(true);
|
||||
try {
|
||||
const blob = await submitForm(file, false);
|
||||
// Trigger browser download
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file instanceof File ? file.name : "filled-form.pdf";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
// Route through the export gateway so a "run on export" policy enforces on
|
||||
// the filled PDF before it leaves the app (no-op when no such policy is set).
|
||||
await downloadFileWithPolicy({
|
||||
data: blob,
|
||||
filename: file instanceof File ? file.name : "filled-form.pdf",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[FormSaveBar] Download failed:", err);
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
// Save through the export gateway so a "run on export" policy enforces before
|
||||
// the file is written out (no-op when no such policy is active).
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
|
||||
/**
|
||||
* Desktop-only keyboard shortcut: Ctrl/Cmd+S to save selected files
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { FileId } from "@app/types/fileContext";
|
||||
import type { OperationSaveContext } from "@core/services/operationResultsSaveService";
|
||||
import {
|
||||
downloadFile,
|
||||
downloadFromUrl,
|
||||
DownloadResult,
|
||||
} from "@app/services/downloadService";
|
||||
import { downloadFromUrl, DownloadResult } from "@app/services/downloadService";
|
||||
// Save through the export gateway so a "run on export" policy enforces before
|
||||
// the file is written out (no-op when no such policy is active).
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
|
||||
export type { OperationSaveContext };
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Compact status row for the {@link enforcementQueue}, shown in the Policies
|
||||
* panel whenever enforcement jobs are pending or running. The queue is serial,
|
||||
* so a slow policy run would otherwise be invisible — this surfaces what's being
|
||||
* enforced (before export, print, convert, …) and how many jobs are waiting.
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Group, Text, Loader } from "@mantine/core";
|
||||
import { useEnforcementQueue } from "@app/components/policies/enforcementQueue";
|
||||
|
||||
export function EnforcementQueueStatus() {
|
||||
const { t } = useTranslation();
|
||||
const jobs = useEnforcementQueue();
|
||||
const active = jobs.filter(
|
||||
(j) => j.status === "pending" || j.status === "running",
|
||||
);
|
||||
if (active.length === 0) return null;
|
||||
|
||||
// The running job leads the row; everything else is still queued behind it.
|
||||
const lead = active.find((j) => j.status === "running") ?? active[0];
|
||||
const queued = active.length - 1;
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
px="sm"
|
||||
py={6}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader size="xs" />
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t(`policies.enforcement.triggerVerb.${lead.trigger}`, {
|
||||
defaultValue: t("policies.enforcement.triggerVerb.default"),
|
||||
})}
|
||||
: {lead.label}
|
||||
{queued > 0
|
||||
? ` · ${t("policies.enforcement.queued", { count: queued })}`
|
||||
: "…"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default EnforcementQueueStatus;
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Serial enforcement queue. Every policy enforcement — before export, before
|
||||
* print, before a convert/extract, and (later) as files arrive — runs through
|
||||
* here, one at a time. The backend rejects concurrent policy runs under load,
|
||||
* and a single in-flight run keeps the queue the user sees honest.
|
||||
*
|
||||
* Jobs carry their {@link EnforcementTrigger} and a status so the UI can show
|
||||
* what's pending/running. Input enforcement reuses this contract unchanged: it
|
||||
* just submits jobs with `trigger: "input"`.
|
||||
*/
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type EnforcementTrigger = "export" | "print" | "convert" | "input";
|
||||
export type EnforcementStatus = "pending" | "running" | "done" | "failed";
|
||||
|
||||
export interface EnforcementJob {
|
||||
id: string;
|
||||
/** Human-readable label, e.g. the policy/file name, shown in the queue UI. */
|
||||
label: string;
|
||||
trigger: EnforcementTrigger;
|
||||
status: EnforcementStatus;
|
||||
}
|
||||
|
||||
/** How long a finished job lingers in the list before it's dropped. */
|
||||
const DONE_LINGER_MS = 2500;
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let jobs: EnforcementJob[] = [];
|
||||
// The tail of the serial chain — each new job runs after this resolves.
|
||||
let tail: Promise<unknown> = Promise.resolve();
|
||||
let seq = 0;
|
||||
|
||||
function emit() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function setStatus(id: string, status: EnforcementStatus) {
|
||||
jobs = jobs.map((j) => (j.id === id ? { ...j, status } : j));
|
||||
emit();
|
||||
}
|
||||
|
||||
function scheduleRemoval(id: string) {
|
||||
setTimeout(() => {
|
||||
jobs = jobs.filter((j) => j.id !== id);
|
||||
emit();
|
||||
}, DONE_LINGER_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `task` after every job queued before it has finished, tracking its status
|
||||
* for the UI. The returned promise resolves/rejects with the task's result, so
|
||||
* callers can `await runQueued(...)` exactly as they would the bare work.
|
||||
*/
|
||||
export function runQueued<T>(
|
||||
meta: { label: string; trigger: EnforcementTrigger },
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const id = `enf-${++seq}`;
|
||||
jobs = [
|
||||
...jobs,
|
||||
{ id, label: meta.label, trigger: meta.trigger, status: "pending" },
|
||||
];
|
||||
emit();
|
||||
|
||||
const run = tail.then(async () => {
|
||||
setStatus(id, "running");
|
||||
try {
|
||||
const result = await task();
|
||||
setStatus(id, "done");
|
||||
return result;
|
||||
} catch (error) {
|
||||
setStatus(id, "failed");
|
||||
throw error;
|
||||
} finally {
|
||||
scheduleRemoval(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the chain alive when a task rejects so the next job still runs; callers
|
||||
// still see the rejection through `run`.
|
||||
tail = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
export function getQueueJobs(): EnforcementJob[] {
|
||||
return jobs;
|
||||
}
|
||||
|
||||
export function subscribeQueue(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** React view of the live queue (pending + running + briefly-lingering jobs). */
|
||||
export function useEnforcementQueue(): EnforcementJob[] {
|
||||
return useSyncExternalStore(subscribeQueue, getQueueJobs, getQueueJobs);
|
||||
}
|
||||
+3
-2
@@ -67,7 +67,7 @@ export default function AdminFeaturesSection() {
|
||||
} = {
|
||||
serverCertificate: systemData.serverCertificate || {
|
||||
enabled: true,
|
||||
organizationName: "Stirling-PDF",
|
||||
organizationName: "Stirling PDF Inc",
|
||||
validity: 365,
|
||||
regenerateOnStartup: false,
|
||||
},
|
||||
@@ -258,7 +258,8 @@ export default function AdminFeaturesSection() {
|
||||
"Organization name for generated certificates",
|
||||
)}
|
||||
value={
|
||||
settings.serverCertificate?.organizationName || "Stirling-PDF"
|
||||
settings.serverCertificate?.organizationName ||
|
||||
"Stirling PDF Inc"
|
||||
}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
|
||||
-82
@@ -35,8 +35,6 @@ interface SecuritySettingsData {
|
||||
loginResetTimeMinutes?: number;
|
||||
xFrameOptions?: string;
|
||||
jwt?: {
|
||||
persistence?: boolean;
|
||||
enableKeyRotation?: boolean;
|
||||
enableKeyCleanup?: boolean;
|
||||
tokenExpiryMinutes?: number;
|
||||
desktopTokenExpiryMinutes?: number;
|
||||
@@ -163,9 +161,6 @@ export default function AdminSecuritySection() {
|
||||
securitySettings.loginResetTimeMinutes,
|
||||
"security.xFrameOptions": securitySettings.xFrameOptions,
|
||||
// JWT settings
|
||||
"security.jwt.persistence": securitySettings.jwt?.persistence,
|
||||
"security.jwt.enableKeyRotation":
|
||||
securitySettings.jwt?.enableKeyRotation,
|
||||
"security.jwt.enableKeyCleanup": securitySettings.jwt?.enableKeyCleanup,
|
||||
"security.jwt.tokenExpiryMinutes":
|
||||
securitySettings.jwt?.tokenExpiryMinutes,
|
||||
@@ -519,83 +514,6 @@ export default function AdminSecuritySection() {
|
||||
{t("admin.settings.security.jwt.label", "JWT Configuration")}
|
||||
</Text>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.jwt.persistence.label",
|
||||
"Enable Key Persistence",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.security.jwt.persistence.description",
|
||||
"Store JWT keys persistently (required for multi-instance deployments)",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
name="jwt_persistence"
|
||||
checked={settings?.jwt?.persistence || false}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
jwt: { ...settings?.jwt, persistence: e.target.checked },
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("jwt.persistence")} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.security.jwt.enableKeyRotation.label",
|
||||
"Enable Key Rotation",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.security.jwt.enableKeyRotation.description",
|
||||
"Automatically rotate JWT signing keys for improved security",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
name="jwt_enableKeyRotation"
|
||||
checked={settings?.jwt?.enableKeyRotation || false}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
jwt: {
|
||||
...settings?.jwt,
|
||||
enableKeyRotation: e.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("jwt.enableKeyRotation")} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
@@ -17,10 +17,18 @@ import {
|
||||
getPolicyRun,
|
||||
downloadPolicyOutput,
|
||||
} from "@app/services/policyApi";
|
||||
import { recordRunStart } from "@app/components/policies/policyRunStore";
|
||||
import {
|
||||
recordRunStart,
|
||||
isDispatched,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
import {
|
||||
runQueued,
|
||||
type EnforcementTrigger,
|
||||
} from "@app/components/policies/enforcementQueue";
|
||||
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
|
||||
import { alert, updateToast, dismissToast } from "@app/components/toast";
|
||||
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
|
||||
import i18n from "@app/i18n";
|
||||
|
||||
/** Poll cadence + cap for a single export run (≈2.5 min worst case). */
|
||||
const POLL_MS = 2000;
|
||||
@@ -98,10 +106,29 @@ async function runToCompletion(
|
||||
if (view.status === "FAILED" || view.status === "CANCELLED") {
|
||||
throw new Error(view.error || `policy run ${view.status.toLowerCase()}`);
|
||||
}
|
||||
if (view.status === "WAITING_FOR_INPUT") {
|
||||
throw new Error(
|
||||
"policy requires interactive input and cannot run automatically",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error("policy run timed out");
|
||||
}
|
||||
|
||||
function enforcedFilesSummary(names: string[]): string {
|
||||
if (names.length === 1) return names[0];
|
||||
if (names.length === 2)
|
||||
return i18n.t("policies.enforcement.summaryTwo", {
|
||||
first: names[0],
|
||||
second: names[1],
|
||||
});
|
||||
return i18n.t("policies.enforcement.summaryMore", {
|
||||
first: names[0],
|
||||
second: names[1],
|
||||
more: names.length - 2,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce every active export-policy on each PDF just before export, returning
|
||||
* the files in order (enforced, or the original on failure). `fileIds[i]` is the
|
||||
@@ -113,80 +140,119 @@ async function runToCompletion(
|
||||
export async function enforceExportPolicies(
|
||||
files: File[],
|
||||
fileIds?: (string | undefined)[],
|
||||
trigger: EnforcementTrigger = "export",
|
||||
): Promise<File[]> {
|
||||
const active = activeExportPolicies();
|
||||
const targets = files.flatMap((f, i) => (isPdf(f) ? [i] : []));
|
||||
if (!active.length || targets.length === 0) return files;
|
||||
|
||||
// Policies that haven't already enforced this exact file version. Enforcing
|
||||
// versions the in-editor file to the policy's output and marks that output
|
||||
// dispatched, so an unedited re-export skips re-running — re-applying a
|
||||
// non-idempotent policy would stack watermarks/flattens. Editing produces a
|
||||
// new file id that isn't dispatched, so an edited file enforces afresh.
|
||||
const pendingFor = (fileId: string | undefined) =>
|
||||
active.filter((p) => !(fileId && isDispatched(p.categoryId, fileId)));
|
||||
if (!targets.some((i) => pendingFor(fileIds?.[i]).length > 0)) return files;
|
||||
|
||||
const names = active.map((p) => p.label).join(", ");
|
||||
const toastId = alert({
|
||||
alertType: "neutral",
|
||||
title: `Applying ${names}`,
|
||||
body: `Enforcing ${
|
||||
targets.length === 1 ? "your file" : `${targets.length} files`
|
||||
} before export…`,
|
||||
isPersistentPopup: true,
|
||||
expandable: false,
|
||||
glowColor: active[0].accent,
|
||||
});
|
||||
|
||||
const out = [...files];
|
||||
let failures = 0;
|
||||
for (const i of targets) {
|
||||
const file = files[i];
|
||||
const fileId = fileIds?.[i];
|
||||
try {
|
||||
let current = file;
|
||||
// The last "new version" policy's output is what versions the editor file
|
||||
// (recording every policy would double-consume the same input).
|
||||
let versionRun: PolicyRunResult & { categoryId: string };
|
||||
let hasVersionRun = false;
|
||||
for (const policy of active) {
|
||||
const result = await runToCompletion(policy.backendId, current);
|
||||
current = result.file;
|
||||
if (policy.outputMode === "new_version" && fileId) {
|
||||
versionRun = { ...result, categoryId: policy.categoryId };
|
||||
hasVersionRun = true;
|
||||
// Serialise through the enforcement queue: one policy run in flight at a time
|
||||
// (the backend rejects concurrent runs under load), and the user can see
|
||||
// what's pending. Export, print and convert all share this queue.
|
||||
return runQueued({ label: names, trigger }, async () => {
|
||||
// An earlier queued job may have just enforced these same files and marked
|
||||
// them dispatched, so re-check at run time before doing (or announcing) work.
|
||||
if (!targets.some((i) => pendingFor(fileIds?.[i]).length > 0)) return files;
|
||||
|
||||
const pending = targets.filter((i) => pendingFor(fileIds?.[i]).length > 0);
|
||||
const total = pending.length;
|
||||
const progressTitle = (done: number) =>
|
||||
total === 1
|
||||
? i18n.t("policies.enforcement.applying", { names })
|
||||
: i18n.t("policies.enforcement.applyingProgress", {
|
||||
names,
|
||||
done: done + 1,
|
||||
total,
|
||||
});
|
||||
const progressBody = (done: number) => files[pending[done]].name;
|
||||
|
||||
const toastId = alert({
|
||||
alertType: "neutral",
|
||||
title: progressTitle(0),
|
||||
body: progressBody(0),
|
||||
isPersistentPopup: true,
|
||||
expandable: false,
|
||||
glowColor: active[0].accent,
|
||||
});
|
||||
|
||||
const out = [...files];
|
||||
let failures = 0;
|
||||
let done = 0;
|
||||
for (const i of pending) {
|
||||
const file = files[i];
|
||||
const fileId = fileIds?.[i];
|
||||
const toRun = pendingFor(fileId);
|
||||
try {
|
||||
let current = file;
|
||||
// The last "new version" policy's output is what versions the editor
|
||||
// file (recording every policy would double-consume the same input).
|
||||
let versionRun: (PolicyRunResult & { categoryId: string }) | undefined;
|
||||
for (const policy of toRun) {
|
||||
const result = await runToCompletion(policy.backendId, current);
|
||||
current = result.file;
|
||||
if (policy.outputMode === "new_version" && fileId) {
|
||||
versionRun = { ...result, categoryId: policy.categoryId };
|
||||
}
|
||||
}
|
||||
out[i] = current;
|
||||
done += 1;
|
||||
if (done < total)
|
||||
updateToast(toastId, {
|
||||
title: progressTitle(done),
|
||||
body: progressBody(done),
|
||||
});
|
||||
if (versionRun && fileId) {
|
||||
recordRunStart({
|
||||
runId: versionRun.runId,
|
||||
categoryId: versionRun.categoryId,
|
||||
fileId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
status: "COMPLETED",
|
||||
outputs: versionRun.outputs,
|
||||
error: null,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
failures += 1; // leave out[i] as the original — never hard-block.
|
||||
}
|
||||
out[i] = current;
|
||||
if (hasVersionRun && fileId) {
|
||||
recordRunStart({
|
||||
runId: versionRun!.runId,
|
||||
categoryId: versionRun!.categoryId,
|
||||
fileId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
status: "COMPLETED",
|
||||
outputs: versionRun!.outputs,
|
||||
error: null,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
failures += 1; // leave out[i] as the original — never hard-block.
|
||||
}
|
||||
}
|
||||
|
||||
updateToast(
|
||||
toastId,
|
||||
failures
|
||||
? {
|
||||
alertType: "warning",
|
||||
title: "Exported without full enforcement",
|
||||
body: `${failures} of ${targets.length} file(s) couldn't be processed and were exported as-is.`,
|
||||
isPersistentPopup: false,
|
||||
glowColor: undefined,
|
||||
}
|
||||
: {
|
||||
alertType: "success",
|
||||
title: `${names} applied`,
|
||||
body: "Enforced before export.",
|
||||
isPersistentPopup: false,
|
||||
glowColor: undefined,
|
||||
},
|
||||
);
|
||||
// update() doesn't reschedule auto-dismiss, so fade the result out explicitly.
|
||||
window.setTimeout(() => dismissToast(toastId), TOAST_LINGER_MS);
|
||||
return out;
|
||||
updateToast(
|
||||
toastId,
|
||||
failures
|
||||
? {
|
||||
alertType: "warning",
|
||||
title: i18n.t("policies.enforcement.failureTitle"),
|
||||
body: i18n.t("policies.enforcement.failureBody", {
|
||||
failures,
|
||||
total,
|
||||
}),
|
||||
isPersistentPopup: false,
|
||||
glowColor: undefined,
|
||||
}
|
||||
: {
|
||||
alertType: "success",
|
||||
title: i18n.t("policies.enforcement.successTitle", { names }),
|
||||
body: enforcedFilesSummary(pending.map((i) => files[i].name)),
|
||||
isPersistentPopup: false,
|
||||
glowColor: undefined,
|
||||
},
|
||||
);
|
||||
// update() doesn't reschedule auto-dismiss, so fade the result out explicitly.
|
||||
window.setTimeout(() => dismissToast(toastId), TOAST_LINGER_MS);
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,55 +1,3 @@
|
||||
/* SaaS-specific auth styles — imported alongside the base auth.css */
|
||||
|
||||
.oauth-container-fullwidth {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
.oauth-button-fullwidth {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 100px;
|
||||
background-color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #000000;
|
||||
cursor: pointer;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04);
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
|
||||
.oauth-button-fullwidth:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.oauth-button-fullwidth:hover:not(:disabled) {
|
||||
background-color: #fafafa;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .oauth-button-fullwidth {
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-default);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.oauth-button-fullwidth:hover:not(:disabled) {
|
||||
background-color: var(--bg-raised);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.auth-dropdown-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -10,3 +10,22 @@ VITE_EDITOR_URL=/
|
||||
# in production builds). The single-origin proxy task sets this to "false" so the
|
||||
# portal uses the real backend.
|
||||
VITE_PORTAL_MOCKS=
|
||||
|
||||
# Hosted SaaS Supabase project for IN-APP account linking (both values are
|
||||
# public). Set per deploy; absent → the account-link UI shows a "configure"
|
||||
# state. For local e2e, point these at the SaaS Supabase project the local
|
||||
# backend links against (e.g. the V3 branch project).
|
||||
VITE_SAAS_SUPABASE_URL=
|
||||
VITE_SAAS_SUPABASE_ANON_KEY=
|
||||
|
||||
# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used
|
||||
# for ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the
|
||||
# admin's Supabase JWT. Distinct from the local backend (which the portal
|
||||
# reaches same-origin via the vite proxy). Absent → wallet/billing surfaces
|
||||
# stay on the MSW mock.
|
||||
VITE_SAAS_API_URL=
|
||||
|
||||
# Stripe publishable key (pk_live_… / pk_test_…) used by the embedded Checkout
|
||||
# in the billing surface. Public by design. Empty → the checkout modal shows a
|
||||
# "configure" state instead of mounting Stripe.
|
||||
VITE_STRIPE_PUBLISHABLE_KEY=
|
||||
|
||||
@@ -49,8 +49,9 @@ appProcessor = "Processor"
|
||||
appEditor = "Editor"
|
||||
docsProcessed = "Docs processed"
|
||||
docsCount = "{{docs}} docs"
|
||||
planPayAsYouGo = "Pay-as-you-go"
|
||||
planEnterprise = "Enterprise Plan"
|
||||
planProcessor = "Processor plan"
|
||||
linkAccount = "Link Stirling account"
|
||||
planEnterprise = "Enterprise plan"
|
||||
|
||||
[search]
|
||||
ariaLabel = "Search"
|
||||
@@ -94,6 +95,7 @@ general = "General"
|
||||
authentication = "Authentication"
|
||||
sessions = "Active sessions"
|
||||
early-access = "Early access"
|
||||
account-link = "Account link"
|
||||
|
||||
[settings.profile]
|
||||
accountFallback = "Account"
|
||||
@@ -420,180 +422,6 @@ snapshot = "Snapshot: re-read the folder every run"
|
||||
[sources.types.unknown]
|
||||
label = "Source"
|
||||
|
||||
[usage]
|
||||
title = "Usage & Billing"
|
||||
subtitle = "Your last 30 days of processing, plan, and charges."
|
||||
|
||||
[usage.chart.empty]
|
||||
title = "No usage yet"
|
||||
description = "Once documents are processed, your 30-day usage appears here."
|
||||
|
||||
[usage.kpi.docsThisPeriod]
|
||||
label = "Docs this period"
|
||||
description = "of {{included}} included"
|
||||
|
||||
[usage.kpi.costThisMonth]
|
||||
label = "Cost this month"
|
||||
description = "incl. {{fee}} platform"
|
||||
freePlan = "free plan"
|
||||
|
||||
[usage.kpi.nextBillingDate]
|
||||
label = "Next billing date"
|
||||
resetsMonthly = "resets monthly"
|
||||
autoCharge = "auto-charge"
|
||||
|
||||
[usage.kpi.remainingInPlan]
|
||||
label = "Remaining in plan"
|
||||
description = "docs before cap"
|
||||
|
||||
[usage.kpi.commitUtilisation]
|
||||
label = "Commit utilisation"
|
||||
description = "of committed volume"
|
||||
|
||||
[usage.kpi.overage]
|
||||
label = "Overage (${{rate}}/doc)"
|
||||
description_one = "{{docs}} doc past cap"
|
||||
description_other = "{{docs}} docs past cap"
|
||||
|
||||
[usage.currentPlan]
|
||||
eyebrow = "Current plan"
|
||||
|
||||
[usage.currentPlan.badge]
|
||||
free = "Free"
|
||||
pro = "Pay-as-you-go"
|
||||
enterprise = "Committed"
|
||||
|
||||
[usage.currentPlan.free]
|
||||
progressLabel = "Free plan usage"
|
||||
|
||||
[usage.currentPlan.free.capReached]
|
||||
title = "You've hit your free plan cap"
|
||||
body = "New documents are paused until next cycle. Upgrade to keep processing without interruption."
|
||||
|
||||
[usage.currentPlan.free.approaching]
|
||||
title = "Approaching your free plan cap"
|
||||
body = "You're at {{pct}}% of 500 docs/month. Upgrade to pay-as-you-go to avoid a pause."
|
||||
|
||||
[usage.currentPlan.pro]
|
||||
platformFee = "Platform fee"
|
||||
includedDocs = "Included docs"
|
||||
overage = "Overage · {{docs}} docs @ ${{rate}}"
|
||||
projected = "Projected this month"
|
||||
|
||||
[usage.currentPlan.enterprise]
|
||||
committedVolume = "Committed volume"
|
||||
committedVolumeValue = "{{docs}} docs/mo"
|
||||
drawnThisPeriod = "Drawn this period"
|
||||
drawnThisPeriodValue = "{{docs}} docs"
|
||||
effectiveRate = "Effective rate"
|
||||
effectiveRateValue = "${{rate}} / doc"
|
||||
monthlyDraw = "Monthly draw"
|
||||
|
||||
[usage.currentPlan.actions]
|
||||
upgrade = "Upgrade plan"
|
||||
talkToSales = "Talk to sales"
|
||||
adjustCommitment = "Adjust commitment"
|
||||
downloadInvoices = "Download invoices"
|
||||
|
||||
[usage.spendCap.free]
|
||||
title = "Spend cap"
|
||||
description = "The free plan can't accrue spend — your usage is hard-capped at 500 docs/month. Upgrade to pay-as-you-go to set a monthly spend cap."
|
||||
|
||||
[usage.spendCap.enterprise]
|
||||
title = "Spend controls"
|
||||
description = "Spend is governed by your committed-volume contract. Overage terms and alert thresholds are managed with your account team."
|
||||
badge = "Committed contract"
|
||||
overage = "Overage billed at ${{rate}}/doc"
|
||||
|
||||
[usage.spendCap.pro]
|
||||
title = "Monthly spend cap"
|
||||
subtitle = "Pause processing automatically when spend reaches your limit."
|
||||
disable = "Disable cap"
|
||||
enable = "Enable cap"
|
||||
projected = "Projected {{projected}} of {{cap}} cap"
|
||||
progressLabel = "Spend against cap"
|
||||
|
||||
[usage.plans]
|
||||
title = "Plans"
|
||||
subtitle = "Move up or down at any time — changes take effect next cycle."
|
||||
|
||||
[usage.planCard]
|
||||
current = "Current"
|
||||
yourPlan = "Your plan"
|
||||
contactSales = "Contact sales"
|
||||
choosePlan = "Choose plan"
|
||||
|
||||
[usage.history]
|
||||
title = "Billing history"
|
||||
subtitle = "Line items from the current and prior billing cycles."
|
||||
emptyRows = "No line items"
|
||||
|
||||
[usage.history.columns]
|
||||
date = "Date"
|
||||
description = "Description"
|
||||
docs = "Docs"
|
||||
amount = "Amount"
|
||||
status = "Status"
|
||||
|
||||
[usage.history.status]
|
||||
paid = "Paid"
|
||||
due = "Due"
|
||||
pending = "Pending"
|
||||
refunded = "Refunded"
|
||||
|
||||
[usage.history.empty]
|
||||
title = "No billing history"
|
||||
description = "Charges and credits appear here once your first cycle closes."
|
||||
|
||||
[usage.upgrade]
|
||||
notNow = "Not now"
|
||||
|
||||
[usage.upgrade.free]
|
||||
title = "Upgrade to keep processing"
|
||||
subtitle = "Pay-as-you-go · $0.05 / doc"
|
||||
body = "You're at the edge of the 500 doc/month free cap. Pay-as-you-go lifts the cap instantly — you only pay for what you process beyond the included 25,000 docs."
|
||||
bullets = [
|
||||
"Lift the 500 doc/month cap immediately",
|
||||
"25,000 docs included, then $0.05/doc",
|
||||
"Unlimited pipelines, agents, and sources",
|
||||
"Set a monthly spend cap to stay in control",
|
||||
]
|
||||
cta = "Switch to pay-as-you-go"
|
||||
|
||||
[usage.upgrade.proToEnterprise]
|
||||
title = "Move to a committed plan"
|
||||
subtitle = "Enterprise · committed annual volume"
|
||||
body = "Your overage is consistent month over month. A committed-volume contract lowers your effective per-doc rate and unlocks dedicated regions, SSO, and a named CSM."
|
||||
bullets = [
|
||||
"Lower effective rate vs metered overage",
|
||||
"Dedicated & on-prem region options",
|
||||
"SSO, audit-log export, signed DPA",
|
||||
"Named CSM and 99.99% SLA",
|
||||
]
|
||||
cta = "Talk to sales"
|
||||
|
||||
[usage.upgrade.pro]
|
||||
title = "You're already on pay-as-you-go"
|
||||
subtitle = "Considering a committed plan?"
|
||||
body = "Pay-as-you-go scales with usage. If your volume is steady, a committed-volume contract typically lowers your effective per-doc rate."
|
||||
bullets = [
|
||||
"Predictable monthly spend",
|
||||
"Lower effective per-doc rate at volume",
|
||||
"Volume discounts kick in past 1M docs/mo",
|
||||
]
|
||||
cta = "Explore committed pricing"
|
||||
|
||||
[usage.upgrade.enterprise]
|
||||
title = "Adjust your commitment"
|
||||
subtitle = "Enterprise · bespoke terms"
|
||||
body = "Your plan is governed by a committed-volume contract. Changes to committed volume, regions, or terms are handled with your account team — they'll model the right shape with you."
|
||||
bullets = [
|
||||
"Re-model committed volume up or down",
|
||||
"Add dedicated or on-prem regions",
|
||||
"Adjust SLA, DPA, and overage terms",
|
||||
]
|
||||
cta = "Contact your CSM"
|
||||
|
||||
[documents]
|
||||
title = "Documents"
|
||||
subtitle = "Review and approve documents moving through your pipelines."
|
||||
@@ -1774,3 +1602,252 @@ redirectingToEditor = "Redirecting to the editor..."
|
||||
title = "Something went wrong on this page"
|
||||
description = "This view hit an unexpected error. Try again, or pick another section from the sidebar."
|
||||
retry = "Try again"
|
||||
|
||||
# ── Account link (combined-billing Mode A) ───────────────────────────────────
|
||||
[accountLink.state]
|
||||
unlinked = "Not linked"
|
||||
free = "Editor plan"
|
||||
subscribed = "Processor plan"
|
||||
|
||||
[accountLink.panel]
|
||||
sub = "Link this self-hosted org to its Stirling account so unattended processing bills against your org wallet."
|
||||
instancesTitle = "Linked instances"
|
||||
instancesSub = "Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access."
|
||||
revokeError = "Couldn't revoke instance"
|
||||
|
||||
[accountLink.panel.loadError]
|
||||
title = "Couldn't load linked instances"
|
||||
forbidden = "Only the team owner can view the org's linked instances."
|
||||
generic = "Couldn't load the team's linked instances. Try again in a moment."
|
||||
|
||||
[accountLink.card]
|
||||
eyebrow = "Account link"
|
||||
title = "Link this org to its Stirling account"
|
||||
linked = "Linked"
|
||||
notLinked = "Not linked"
|
||||
unlink = "Unlink"
|
||||
linkButton = "Link your Stirling account"
|
||||
linkedAs = "Linked as {{name}}."
|
||||
linkedGeneric = "This instance is linked."
|
||||
billingNote = "Unattended processing bills against your org wallet."
|
||||
|
||||
[accountLink.card.error]
|
||||
title = "Couldn't link"
|
||||
|
||||
[accountLink.card.loginNotConfigured]
|
||||
title = "SaaS login not configured"
|
||||
before = "Set"
|
||||
after = "to enable account linking against the hosted Stirling account. In dev you can simulate sign-in from the link dialog."
|
||||
|
||||
[accountLink.modal]
|
||||
linkTitle = "Link your Stirling account"
|
||||
reauthTitle = "Sign in again"
|
||||
linkSubtitle = "Sign in to the account this server should bill against."
|
||||
reauthSubtitle = "Your session expired — sign back in to your Stirling account. Your instance stays linked."
|
||||
simulateSignIn = "Simulate sign-in (dev)"
|
||||
|
||||
[accountLink.modal.loginNotConfigured]
|
||||
title = "SaaS login not configured"
|
||||
before = "Set"
|
||||
and = "and"
|
||||
after = "to enable in-app linking against the hosted Stirling account."
|
||||
|
||||
[accountLink.gate]
|
||||
title = "Link to unlock"
|
||||
titleFeature = "Link to unlock {{feature}}"
|
||||
description = "Link this org's Stirling account to use billable features."
|
||||
action = "Link account"
|
||||
|
||||
[accountLink.instances]
|
||||
unnamed = "Unnamed instance"
|
||||
revoked = "Revoked"
|
||||
active = "Active"
|
||||
revoke = "Revoke"
|
||||
|
||||
[accountLink.instances.columns]
|
||||
instance = "Instance"
|
||||
status = "Status"
|
||||
lastSeen = "Last seen"
|
||||
linked = "Linked"
|
||||
|
||||
[accountLink.instances.empty]
|
||||
title = "No linked instances"
|
||||
description = "Link this org's account, then register your self-hosted instances to see them here."
|
||||
|
||||
[accountLink.instances.time]
|
||||
never = "never"
|
||||
justNow = "just now"
|
||||
minutesAgo_one = "{{count}}m ago"
|
||||
minutesAgo_other = "{{count}}m ago"
|
||||
hoursAgo_one = "{{count}}h ago"
|
||||
hoursAgo_other = "{{count}}h ago"
|
||||
daysAgo_one = "{{count}}d ago"
|
||||
daysAgo_other = "{{count}}d ago"
|
||||
|
||||
# ── Billing surface (Usage & billing) ────────────────────────────────────────
|
||||
[billing.enterpriseUpsell]
|
||||
eyebrow = "Volume discount · 1M+ PDFs"
|
||||
title = "Stirling Enterprise"
|
||||
description = "Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships."
|
||||
cta = "Build your Enterprise quote"
|
||||
|
||||
[billing.freeEditors]
|
||||
title = "Free PDF Editors"
|
||||
previewBadge = "Preview · sample data"
|
||||
subtitle = "Deploy anywhere, for your whole team."
|
||||
editorsDeployed = "Editors deployed"
|
||||
activeThisMonth = "Active this month"
|
||||
pdfsEdited = "PDFs edited"
|
||||
cost = "Cost"
|
||||
inviteTeammates = "Invite teammates"
|
||||
|
||||
[billing.freePlan]
|
||||
currentPlan = "Current plan"
|
||||
planName = "Editor"
|
||||
freeForever = "Free forever"
|
||||
ssoIncluded = "SSO included"
|
||||
unlimitedUsers = "Unlimited users"
|
||||
switchOnProcessor = "Switch on the Processor →"
|
||||
noTeamResolved = "No team is resolved on your wallet yet — refresh and try again."
|
||||
checkoutErrorTitle = "Couldn't start checkout"
|
||||
ownerOnly = "Only the team owner can switch on the Processor plan."
|
||||
|
||||
[billing.linkPrompt]
|
||||
title = "Link your Stirling account"
|
||||
description = "Manual PDF editing — view, sign, merge, split, watermark, compress, convert, manual OCR — is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use."
|
||||
cta = "Link Stirling account"
|
||||
|
||||
[billing.walletMeter]
|
||||
eyebrow = "Processor trial"
|
||||
sub = "Use the PDF Editor for free. Pay to process PDFs automatically."
|
||||
title_one = "Process {{allowance}} PDFs free"
|
||||
title_other = "Process {{allowance}} PDFs free"
|
||||
titleWithRate_one = "Process {{allowance}} PDFs free, then {{rate}}/PDF"
|
||||
titleWithRate_other = "Process {{allowance}} PDFs free, then {{rate}}/PDF"
|
||||
capSuffix_one = "of {{allowance}} free PDFs used"
|
||||
capSuffix_other = "of {{allowance}} free PDFs used"
|
||||
statusLabel_one = "{{remaining}} left"
|
||||
statusLabel_other = "{{remaining}} left"
|
||||
|
||||
[billing.pdfsProcessed]
|
||||
eyebrow = "PDFs processed this period"
|
||||
unit = "metered PDFs"
|
||||
segbarAriaLabel = "Metered PDFs split by category"
|
||||
segmentApiLabel = "API"
|
||||
segmentApiDesc = "Direct API requests"
|
||||
segmentAgentsLabel = "Agents"
|
||||
segmentAgentsDesc = "AI agent actions"
|
||||
segmentAutomationLabel = "Automation"
|
||||
segmentAutomationDesc = "Automations & pipelines"
|
||||
legendValue_one = "{{formatted}} PDFs"
|
||||
legendValue_other = "{{formatted}} PDFs"
|
||||
emptyPeriod = "No metered processing yet this period."
|
||||
|
||||
[billing.spendThisMonth]
|
||||
eyebrow = "Spend this month"
|
||||
processed_one = "{{formattedCount}} PDF processed."
|
||||
processed_other = "{{formattedCount}} PDFs processed."
|
||||
processedWithRate_one = "{{formattedCount}} PDF processed, at {{rate}} each."
|
||||
processedWithRate_other = "{{formattedCount}} PDFs processed, at {{rate}} each."
|
||||
|
||||
[billing.spendLimit]
|
||||
eyebrow = "Spend limit"
|
||||
editTitle = "Set your monthly ceiling"
|
||||
capControlNote = "Changes apply immediately — raise or lower the ceiling any time."
|
||||
useSuggested = "Use suggested · {{amount}} / month"
|
||||
guardrailLabel = "Your guardrail:"
|
||||
guardrailBody = "a hard ceiling — you're never billed past it. At the cap, metered processing pauses (unlimited PDF editing keeps working) until you raise it or the cycle resets. Nothing is lost."
|
||||
saveError = "Couldn't save limit"
|
||||
cancel = "Cancel"
|
||||
save = "Save limit"
|
||||
displaySub = "You're only billed for what you process automatically — never past the ceiling."
|
||||
adjustLimit = "Adjust limit"
|
||||
capSuffix = "/ month"
|
||||
capSuffixWithDocs = "/ month · ≈ {{documents}} documents"
|
||||
noCap = "no cap"
|
||||
pctUsed = "{{pct}}% used"
|
||||
usedThisMonth = "{{amount}} used this month"
|
||||
remaining = "{{amount}} remaining"
|
||||
thisPeriodUncapped = "{{amount}} this period · uncapped"
|
||||
|
||||
[billing.spendLimit.projection]
|
||||
label = "Projected to exceed."
|
||||
body_one = "At {{rate}}/day you reach the cap in ~{{count}} day (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}."
|
||||
body_other = "At {{rate}}/day you reach the cap in ~{{count}} days (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}."
|
||||
|
||||
[billing.invoices]
|
||||
title = "Invoice history"
|
||||
columnDate = "Date"
|
||||
columnPdfsProcessed = "PDFs processed"
|
||||
columnAmount = "Amount"
|
||||
columnStatus = "Status"
|
||||
columnDescription = "Description"
|
||||
descriptionFallback = "Invoice"
|
||||
viewLink = "View ↗"
|
||||
viewAriaLabel = "View invoice {{number}} in Stripe"
|
||||
pdfLink = "PDF ↓"
|
||||
downloadAriaLabel = "Download invoice {{number}} as PDF"
|
||||
loadError = "Couldn't load invoices: {{error}}"
|
||||
emptyTitle = "No invoices yet"
|
||||
emptyDescription = "Once your team subscribes and the first cycle closes, your invoices appear here."
|
||||
showFewer_one = "Show fewer (top {{count}})"
|
||||
showFewer_other = "Show fewer (top {{count}})"
|
||||
showMostRecent_one = "Show {{count}} most recent"
|
||||
showMostRecent_other = "Show {{count}} most recent"
|
||||
showAll_one = "Show all {{count}}"
|
||||
showAll_other = "Show all {{count}}"
|
||||
fetchLimitNote_one = "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal."
|
||||
fetchLimitNote_other = "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal."
|
||||
|
||||
[billing.paymentMethod]
|
||||
eyebrow = "Payment method"
|
||||
cardEnding = "{{brand}} ending {{last4}}"
|
||||
cardFallback = "Card"
|
||||
expiresBilledMonthly = "Expires {{expiry}} · billed monthly"
|
||||
billedMonthly = "Billed monthly"
|
||||
managedTitle = "Managed in Stripe"
|
||||
managedSub = "Your card and billing details are kept securely in Stripe's customer portal."
|
||||
update = "Update"
|
||||
|
||||
[billing.checkout]
|
||||
title = "Turn on the Processor plan"
|
||||
subtitle = "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest."
|
||||
noClientSecret = "Edge function returned no client_secret."
|
||||
|
||||
[billing.checkout.notConfigured]
|
||||
title = "Stripe not configured"
|
||||
bodyBefore = "Set"
|
||||
bodyAfter = "in the portal env to enable in-app checkout."
|
||||
|
||||
[billing.checkout.error]
|
||||
title = "Couldn't start checkout"
|
||||
|
||||
[billing.subscribedPlan.capWarn]
|
||||
reachedTitle = "Monthly spend limit reached"
|
||||
approachingTitle = "You're at {{pct}}% of your monthly spend limit"
|
||||
raiseLimit = "Raise limit"
|
||||
reachedBody = "Metered processing is paused until you raise the limit or the cycle resets. Unlimited PDF editing keeps working."
|
||||
approachingBody = "Raise it now so automated processing never pauses."
|
||||
|
||||
[billing.subscribedPlan.portalError]
|
||||
title = "Couldn't open Stripe portal"
|
||||
|
||||
# ── Usage & billing view ─────────────────────────────────────────────────────
|
||||
[usage]
|
||||
title = "Usage & billing"
|
||||
subtitle = "Consumption, invoices, and plan management for every PDF Stirling has billed, in one console."
|
||||
managePayment = "Manage Payment"
|
||||
|
||||
[usage.finalizing]
|
||||
title = "Finalizing your subscription…"
|
||||
body = "It can take a few seconds for your subscription to activate. This page updates automatically."
|
||||
|
||||
[usage.sessionExpired]
|
||||
title = "Session expired"
|
||||
action = "Sign in again"
|
||||
body = "Your Stirling account session has expired. Sign in again to view billing — your instance stays linked."
|
||||
|
||||
[usage.error]
|
||||
loadWallet = "Couldn't load wallet"
|
||||
openStripePortal = "Couldn't open Stripe portal"
|
||||
walletUnavailable = "Wallet unavailable: {{status}} {{statusText}}"
|
||||
|
||||
+66
-18
@@ -5,6 +5,8 @@ import { AuthProvider } from "@shared/auth";
|
||||
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
|
||||
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { TierProvider } from "@portal/contexts/TierContext";
|
||||
import { LinkProvider, useLink } from "@portal/contexts/LinkContext";
|
||||
import type { SupabaseLoginSession } from "@shared/auth/ui/useSupabaseLogin";
|
||||
import { UIProvider, useUI } from "@portal/contexts/UIContext";
|
||||
import { mantineTheme } from "@portal/theme/mantineTheme";
|
||||
import { AppShell } from "@portal/components/AppShell";
|
||||
@@ -13,6 +15,11 @@ import { AssistantButton } from "@portal/components/AssistantButton";
|
||||
import { AssistantPanel } from "@portal/components/AssistantPanel";
|
||||
import { SearchModal } from "@portal/components/SearchModal";
|
||||
import { SettingsModal } from "@portal/components/SettingsModal";
|
||||
import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal";
|
||||
import {
|
||||
AccountLinkProvider,
|
||||
useAccountLinkContext,
|
||||
} from "@portal/contexts/AccountLinkContext";
|
||||
import { ViewRouter } from "@portal/ViewRouter";
|
||||
|
||||
/**
|
||||
@@ -58,8 +65,42 @@ function GlobalShortcuts() {
|
||||
|
||||
/** Bridges the Settings modal's open/close props to UIContext state. */
|
||||
function SettingsHost() {
|
||||
const { settingsOpen, closeSettings } = useUI();
|
||||
return <SettingsModal open={settingsOpen} onClose={closeSettings} />;
|
||||
const { settingsOpen, settingsInitialSection, closeSettings } = useUI();
|
||||
return (
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={closeSettings}
|
||||
initialSection={settingsInitialSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one and only account-link login modal. Mounted at the app root (never
|
||||
* nested in another overlay) and driven by UIContext, so any "Link account" CTA
|
||||
* — sidebar, billing prompt, feature gate, Settings panel — opens this exact
|
||||
* instance. Linking is finished by the shared {@link useAccountLinkContext}
|
||||
* orchestration.
|
||||
*/
|
||||
function LinkModalHost() {
|
||||
const { linkModalOpen, linkModalMode, closeLinkModal } = useUI();
|
||||
const { markSaasSessionChanged } = useLink();
|
||||
const link = useAccountLinkContext();
|
||||
// "reauth" only refreshes the browser SaaS session for attended reads — the
|
||||
// sign-in already applied it to the Supabase client, so we just signal a
|
||||
// refetch. It must NOT call completeLink (that re-registers → duplicate row).
|
||||
const onLinked =
|
||||
linkModalMode === "reauth"
|
||||
? () => markSaasSessionChanged()
|
||||
: (session: SupabaseLoginSession) => link.completeLink(session);
|
||||
return (
|
||||
<LinkAccountModal
|
||||
open={linkModalOpen}
|
||||
mode={linkModalMode}
|
||||
onClose={closeLinkModal}
|
||||
onLinked={onLinked}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,22 +128,29 @@ export function App() {
|
||||
<ThemeProvider>
|
||||
<PortalMantineProvider>
|
||||
<AuthProvider mode="spring">
|
||||
<TierProvider initialTier="pro">
|
||||
<BrowserRouter basename={basename}>
|
||||
<UIProvider>
|
||||
<GlobalShortcuts />
|
||||
<AuthGate>
|
||||
<AppShell>
|
||||
<RoutedContent />
|
||||
</AppShell>
|
||||
<AssistantButton />
|
||||
<AssistantPanel />
|
||||
<SearchModal />
|
||||
<SettingsHost />
|
||||
</AuthGate>
|
||||
</UIProvider>
|
||||
</BrowserRouter>
|
||||
</TierProvider>
|
||||
<LinkProvider initialState="unlinked">
|
||||
{/* TierProvider sits INSIDE LinkProvider so it can derive the tier
|
||||
from the real link/subscription state when MSW mocks are off. */}
|
||||
<TierProvider initialTier="pro">
|
||||
<BrowserRouter basename={basename}>
|
||||
<UIProvider>
|
||||
<GlobalShortcuts />
|
||||
<AuthGate>
|
||||
<AccountLinkProvider>
|
||||
<AppShell>
|
||||
<RoutedContent />
|
||||
</AppShell>
|
||||
<AssistantButton />
|
||||
<AssistantPanel />
|
||||
<SearchModal />
|
||||
<SettingsHost />
|
||||
<LinkModalHost />
|
||||
</AccountLinkProvider>
|
||||
</AuthGate>
|
||||
</UIProvider>
|
||||
</BrowserRouter>
|
||||
</TierProvider>
|
||||
</LinkProvider>
|
||||
</AuthProvider>
|
||||
</PortalMantineProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -28,6 +28,8 @@ export function ViewRouter() {
|
||||
<Route path={VIEW_PATHS.infrastructure} element={<Infrastructure />} />
|
||||
<Route path={VIEW_PATHS.usage} element={<Usage />} />
|
||||
<Route path={VIEW_PATHS.docs} element={<DeveloperDocs />} />
|
||||
{/* Account-link is now a Settings panel; redirect legacy bookmarks home. */}
|
||||
<Route path="/account-link" element={<Navigate to="/" replace />} />
|
||||
{/* Settings is a modal overlay, not a route (see AppShell + UIContext). */}
|
||||
{/* Unknown paths land on Home. */}
|
||||
<Route path="*" element={<Navigate to={VIEW_PATHS.home} replace />} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { AgentsResponse } from "@portal/mocks/agents";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
@@ -16,7 +16,7 @@ export { AGENT_STATUS_TONE, TOOL_CATALOGUE } from "@portal/mocks/agents";
|
||||
|
||||
/** GET /v1/agents?tier=… — fleet summary + every agent with its full builder state. */
|
||||
export async function fetchAgents(tier: Tier): Promise<AgentsResponse> {
|
||||
return httpJson<AgentsResponse>(
|
||||
return apiClient.local.json<AgentsResponse>(
|
||||
`/v1/agents?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
|
||||
/** GET /v1/assistant/suggestions */
|
||||
export async function fetchAssistantSuggestions(): Promise<readonly string[]> {
|
||||
return httpJson<readonly string[]>("/v1/assistant/suggestions");
|
||||
return apiClient.local.json<readonly string[]>("/v1/assistant/suggestions");
|
||||
}
|
||||
|
||||
/** POST /v1/assistant/messages */
|
||||
export async function getAssistantReply(input: string): Promise<string> {
|
||||
const res = await httpJson<{ reply: string }>("/v1/assistant/messages", {
|
||||
method: "POST",
|
||||
body: { input },
|
||||
});
|
||||
const res = await apiClient.local.json<{ reply: string }>(
|
||||
"/v1/assistant/messages",
|
||||
{
|
||||
method: "POST",
|
||||
body: { input },
|
||||
},
|
||||
);
|
||||
return res.reply;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { Wallet } from "@shared/billing";
|
||||
|
||||
/**
|
||||
* Real wallet + billing surface. All calls go to apiClient.saas — the hosted
|
||||
* SaaS Java backend, authed by the admin's Supabase JWT. The wallet contract
|
||||
* itself lives in {@code @shared/billing} (shared with the editor cloud surface).
|
||||
*/
|
||||
|
||||
// Re-export the shared contract so existing `@portal/api/billing` importers keep working.
|
||||
export type {
|
||||
Wallet,
|
||||
WalletStatus,
|
||||
WalletRole,
|
||||
WalletMember,
|
||||
WalletCategoryBreakdown,
|
||||
WalletActivityRow,
|
||||
} from "@shared/billing";
|
||||
|
||||
export async function fetchWallet(): Promise<Wallet> {
|
||||
return apiClient.saas.json<Wallet>("/api/v1/payg/wallet");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Cap — leader-only PATCH (real endpoint).
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function updateCap(capUsd: number | null): Promise<void> {
|
||||
await apiClient.saas.json<void>("/api/v1/payg/cap", {
|
||||
method: "PATCH",
|
||||
body: { capUsd: capUsd ?? 0, noCap: capUsd === null },
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Invoices — backed by GET /api/v1/payg/invoices (reads stripe.invoices via
|
||||
// the Sync Engine). Returns [] for free teams + missing schema.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
number: string | null;
|
||||
status: string;
|
||||
totalMinor: number | null;
|
||||
currency: string | null;
|
||||
createdAt: string | null;
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
hostedInvoiceUrl: string | null;
|
||||
invoicePdf: string | null;
|
||||
/** Product name from the subscription chain (e.g. "Stirling Processor Plan"). */
|
||||
description: string | null;
|
||||
/** Billed units (PDFs) on this invoice; null when the line-item table isn't synced. */
|
||||
pdfsProcessed: number | null;
|
||||
}
|
||||
|
||||
export async function fetchInvoices(limit: number = 20): Promise<Invoice[]> {
|
||||
return apiClient.saas.json<Invoice[]>(
|
||||
`/api/v1/payg/invoices?limit=${encodeURIComponent(String(limit))}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Payment method — GET /api/v1/payg/payment-method. Reads the default card off
|
||||
// the Stripe mirror; `present: false` when the mirror doesn't carry one (table
|
||||
// not synced / no card). Card edits happen in Stripe's portal, not here.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaymentMethod {
|
||||
present: boolean;
|
||||
brand: string | null;
|
||||
last4: string | null;
|
||||
expMonth: number | null;
|
||||
expYear: number | null;
|
||||
}
|
||||
|
||||
export async function fetchPaymentMethod(): Promise<PaymentMethod> {
|
||||
return apiClient.saas.json<PaymentMethod>("/api/v1/payg/payment-method");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type { DocsContent, DocsNavSection } from "@portal/mocks/docs";
|
||||
|
||||
@@ -18,10 +18,10 @@ export type {
|
||||
|
||||
/** GET /v1/docs/nav — the docs nav tree. */
|
||||
export async function fetchDocsNav(): Promise<DocsNavSection[]> {
|
||||
return httpJson<DocsNavSection[]>("/v1/docs/nav");
|
||||
return apiClient.local.json<DocsNavSection[]>("/v1/docs/nav");
|
||||
}
|
||||
|
||||
/** GET /v1/docs/content — the tier-scaled reference content. */
|
||||
export async function fetchDocsContent(tier: Tier): Promise<DocsContent> {
|
||||
return httpJson<DocsContent>(`/v1/docs/content?tier=${tier}`);
|
||||
return apiClient.local.json<DocsContent>(`/v1/docs/content?tier=${tier}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { DocumentsResponse } from "@portal/mocks/documents";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
@@ -20,7 +20,7 @@ export {
|
||||
|
||||
/** GET /v1/documents?tier=… — summary strip + the review queue for the tier. */
|
||||
export async function fetchDocuments(tier: Tier): Promise<DocumentsResponse> {
|
||||
return httpJson<DocumentsResponse>(
|
||||
return apiClient.local.json<DocumentsResponse>(
|
||||
`/v1/documents?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { EditorDeploymentResponse } from "@portal/mocks/editorDeploy";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
@@ -29,7 +29,7 @@ export {
|
||||
export async function fetchEditorDeployment(
|
||||
tier: Tier,
|
||||
): Promise<EditorDeploymentResponse> {
|
||||
return httpJson<EditorDeploymentResponse>(
|
||||
return apiClient.local.json<EditorDeploymentResponse>(
|
||||
`/v1/editor/deployment?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type {
|
||||
ActivityEvent,
|
||||
KpiEntry,
|
||||
@@ -23,25 +23,29 @@ export { PIPELINE_STAGES, PIPELINE_TEMPLATES } from "@portal/mocks/home";
|
||||
|
||||
/** GET /v1/analytics/usage?window=30d */
|
||||
export async function fetchUsageSeries(): Promise<UsageSeriesResponse> {
|
||||
return httpJson<UsageSeriesResponse>("/v1/analytics/usage?window=30d");
|
||||
return apiClient.local.json<UsageSeriesResponse>(
|
||||
"/v1/analytics/usage?window=30d",
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /v1/activity?limit=8 */
|
||||
export async function fetchRecentActivity(): Promise<ActivityEvent[]> {
|
||||
return httpJson<ActivityEvent[]>("/v1/activity?limit=8");
|
||||
return apiClient.local.json<ActivityEvent[]>("/v1/activity?limit=8");
|
||||
}
|
||||
|
||||
/** GET /v1/home/kpis?tier=… */
|
||||
export async function fetchHomeKpis(tier: Tier): Promise<KpiEntry[]> {
|
||||
return httpJson<KpiEntry[]>(`/v1/home/kpis?tier=${encodeURIComponent(tier)}`);
|
||||
return apiClient.local.json<KpiEntry[]>(
|
||||
`/v1/home/kpis?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /v1/regions/health (Enterprise) */
|
||||
export async function fetchRegionHealth(): Promise<RegionHealth[]> {
|
||||
return httpJson<RegionHealth[]>("/v1/regions/health");
|
||||
return apiClient.local.json<RegionHealth[]>("/v1/regions/health");
|
||||
}
|
||||
|
||||
/** GET /v1/onboarding (Free) */
|
||||
export async function fetchOnboarding(): Promise<OnboardingStep[]> {
|
||||
return httpJson<OnboardingStep[]>("/v1/onboarding");
|
||||
return apiClient.local.json<OnboardingStep[]>("/v1/onboarding");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* api/http apiClient routing + error branches — the module exists specifically
|
||||
* to make portal→backend routing explicit after /v1/billing/wallet once fell
|
||||
* through to the local backend. The happy-path routing (saas hits the absolute
|
||||
* base with the Supabase bearer) is covered in api/link.test.ts; here we pin the
|
||||
* error/edge branches that gate the billing UI's error surface.
|
||||
*/
|
||||
const { getSession, getStoredTokenMock } = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
getStoredTokenMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@shared/auth", () => ({ getStoredToken: getStoredTokenMock }));
|
||||
vi.mock("@shared/auth/supabase/supabaseClient", () => ({
|
||||
getSupabaseClient: () => ({ auth: { getSession } }),
|
||||
configureSupabase: vi.fn(),
|
||||
}));
|
||||
vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() }));
|
||||
|
||||
import {
|
||||
apiClient,
|
||||
HttpError,
|
||||
SaasNotLinkedError,
|
||||
SaasUnconfiguredError,
|
||||
} from "@portal/api/http";
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchMock.mockReset();
|
||||
getSession.mockReset();
|
||||
getStoredTokenMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function ok(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("apiClient.saas", () => {
|
||||
it("throws SaasUnconfiguredError when VITE_SAAS_API_URL is unset", async () => {
|
||||
vi.stubEnv("VITE_SAAS_API_URL", "");
|
||||
await expect(
|
||||
apiClient.saas.json("/api/v1/payg/wallet"),
|
||||
).rejects.toBeInstanceOf(SaasUnconfiguredError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws SaasNotLinkedError when there is no SaaS session", async () => {
|
||||
vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local");
|
||||
getSession.mockResolvedValue({ data: { session: null } });
|
||||
await expect(
|
||||
apiClient.saas.json("/api/v1/payg/wallet"),
|
||||
).rejects.toBeInstanceOf(SaasNotLinkedError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches the Supabase bearer and hits the absolute SaaS base", async () => {
|
||||
vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local");
|
||||
getSession.mockResolvedValue({
|
||||
data: { session: { access_token: "supabase_tok" } },
|
||||
});
|
||||
fetchMock.mockResolvedValue(ok({ status: "free" }));
|
||||
|
||||
const body = await apiClient.saas.json<{ status: string }>(
|
||||
"/api/v1/payg/wallet",
|
||||
);
|
||||
|
||||
expect(body.status).toBe("free");
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://saas.test.local/api/v1/payg/wallet");
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer supabase_tok",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiClient.local", () => {
|
||||
it("attaches the Spring admin bearer and stays same-origin", async () => {
|
||||
getStoredTokenMock.mockReturnValue("spring_tok");
|
||||
fetchMock.mockResolvedValue(ok({ linked: false }));
|
||||
|
||||
await apiClient.local.json("/api/v1/account-link/status");
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/account-link/status");
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer spring_tok",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for a 204 response", async () => {
|
||||
getStoredTokenMock.mockReturnValue("spring_tok");
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
|
||||
|
||||
const result = await apiClient.local.json("/api/v1/account-link/unlink", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws HttpError with the status and parsed body on non-2xx", async () => {
|
||||
getStoredTokenMock.mockReturnValue("spring_tok");
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "nope" }), {
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const err = await apiClient.local
|
||||
.json("/api/v1/account-link/status")
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(HttpError);
|
||||
expect((err as HttpError).status).toBe(500);
|
||||
expect((err as HttpError).body).toEqual({ error: "nope" });
|
||||
});
|
||||
});
|
||||
+150
-25
@@ -1,16 +1,54 @@
|
||||
/**
|
||||
* Shared HTTP plumbing for the portal's service layer.
|
||||
* Portal API client — explicit per-backend, per-credential routing.
|
||||
*
|
||||
* Every `api/*.ts` module calls {@link httpJson}, which issues a real `fetch`.
|
||||
* In dev and Storybook those requests are intercepted by the MSW handlers in
|
||||
* `mocks/` and answered with fixture data; pointing at a real backend is just
|
||||
* a matter of not registering MSW. Consumers don't change either way.
|
||||
* ## Domains
|
||||
*
|
||||
* The shared `stirling_jwt` bearer token (set by the auth gate, and shared
|
||||
* same-origin with the editor) is attached automatically so portal data calls
|
||||
* are authenticated once real backend endpoints exist.
|
||||
* apiClient.local Same-origin (vite proxy → this instance's local
|
||||
* Stirling backend on :8080). Spring admin bearer
|
||||
* (`stirling_jwt` from @shared/auth) auto-attached.
|
||||
* USE FOR: actions on this instance —
|
||||
* /api/v1/account-link/{status,link,unlink}, etc.
|
||||
*
|
||||
* apiClient.saas VITE_SAAS_API_URL (hosted SaaS Java). The admin's
|
||||
* Supabase JWT (from the account-link login,
|
||||
* persisted + SDK-refreshed) is auto-attached.
|
||||
* USE FOR: attended portal→SaaS reads —
|
||||
* /api/v1/payg/wallet, etc.
|
||||
* Throws SaasUnconfiguredError when VITE_SAAS_API_URL
|
||||
* is missing — callers surface a clear "configure"
|
||||
* state rather than silently routing to the wrong
|
||||
* domain.
|
||||
*
|
||||
* Endpoints that don't have a real backend yet still target their eventual
|
||||
* domain (almost always `.local`): with Mocks=on the MSW handlers intercept;
|
||||
* with Mocks=off they hit the real backend and 404 until the route ships, then
|
||||
* self-heal — no call-site migration needed.
|
||||
*
|
||||
* ## Why this is split, not a single function
|
||||
*
|
||||
* The two backends speak two credentials and resolve different identities. A
|
||||
* single generic fetch reading the path prefix to pick a domain is implicit +
|
||||
* fragile (the bug we hit: /v1/billing/wallet fell through to the local
|
||||
* backend on a real run). Forcing the call site to say `.local` / `.saas`
|
||||
* keeps the routing intent reviewable in diffs.
|
||||
*
|
||||
* ## Device credential isn't here
|
||||
*
|
||||
* The instance↔SaaS device credential ({@code X-Device-Id}+{@code X-Device-Secret})
|
||||
* is a server-side credential the local backend uses for UNATTENDED metering /
|
||||
* entitlement calls. It never enters the portal — the browser is the human
|
||||
* admin and uses the Supabase JWT for SaaS reads. Don't add it here.
|
||||
*/
|
||||
import { getStoredToken } from "@shared/auth";
|
||||
import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
|
||||
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
|
||||
|
||||
/** Read the SaaS base URL at call time so tests can stub it via vi.stubEnv. */
|
||||
function saasBaseUrl(): string | null {
|
||||
const raw = import.meta.env.VITE_SAAS_API_URL;
|
||||
if (!raw) return null;
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export interface HttpRequestOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
@@ -20,6 +58,7 @@ export interface HttpRequestOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Thrown by any apiClient call on non-2xx response, with the parsed body. */
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
@@ -31,6 +70,26 @@ export class HttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown by apiClient.saas.* when VITE_SAAS_API_URL isn't set. */
|
||||
export class SaasUnconfiguredError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"SaaS API not configured — set VITE_SAAS_API_URL to enable portal→SaaS reads.",
|
||||
);
|
||||
this.name = "SaasUnconfiguredError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown by apiClient.saas.* when the admin has no SaaS session yet. */
|
||||
export class SaasNotLinkedError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"No SaaS session — admin must link an account before attended SaaS reads.",
|
||||
);
|
||||
this.name = "SaasNotLinkedError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort human-readable message from a thrown error: unwraps an
|
||||
* {@link HttpError}'s ProblemDetail-ish body (`detail` / `message` / `error`)
|
||||
@@ -49,16 +108,38 @@ export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function authHeader(): Record<string, string> {
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Shared response handler
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function unwrap<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
// ignore — non-JSON error response
|
||||
}
|
||||
throw new HttpError(res.status, res.statusText, body);
|
||||
}
|
||||
// 204 / empty-body responses have nothing to parse.
|
||||
if (res.status === 204 || res.headers.get("Content-Length") === "0") {
|
||||
return undefined as T;
|
||||
}
|
||||
const text = await res.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// local — same-origin Stirling backend, Spring admin bearer
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function localAuthHeader(): Record<string, string> {
|
||||
const token = getStoredToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin JSON fetch wrapper used by every api module. In dev/Storybook the
|
||||
* request is served by MSW; against a real backend it hits the network.
|
||||
*/
|
||||
export async function httpJson<T>(
|
||||
async function localJson<T>(
|
||||
path: string,
|
||||
options: HttpRequestOptions = {},
|
||||
): Promise<T> {
|
||||
@@ -69,20 +150,64 @@ export async function httpJson<T>(
|
||||
...(options.body !== undefined
|
||||
? { "Content-Type": "application/json" }
|
||||
: {}),
|
||||
...authHeader(),
|
||||
...localAuthHeader(),
|
||||
...options.headers,
|
||||
},
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
// ignore — non-JSON error response
|
||||
}
|
||||
throw new HttpError(res.status, res.statusText, body);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
return unwrap<T>(res);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// saas — hosted SaaS Java, admin's Supabase JWT
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getSaasAccessToken(): Promise<string | null> {
|
||||
ensureSaasSupabase();
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) return null;
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data.session?.access_token ?? null;
|
||||
}
|
||||
|
||||
async function saasJson<T>(
|
||||
path: string,
|
||||
options: HttpRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const base = saasBaseUrl();
|
||||
if (!base) throw new SaasUnconfiguredError();
|
||||
const token = await getSaasAccessToken();
|
||||
if (!token) throw new SaasNotLinkedError();
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method: options.method ?? "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(options.body !== undefined
|
||||
? { "Content-Type": "application/json" }
|
||||
: {}),
|
||||
...options.headers,
|
||||
},
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
signal: options.signal,
|
||||
});
|
||||
return unwrap<T>(res);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Exported API client
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const apiClient = {
|
||||
/** Local backend (this instance). Spring admin bearer auto-attached. */
|
||||
local: {
|
||||
json: localJson,
|
||||
},
|
||||
/** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */
|
||||
saas: {
|
||||
json: saasJson,
|
||||
/** True when VITE_SAAS_API_URL is set. Doesn't check session liveness. */
|
||||
isConfigured: (): boolean => Boolean(saasBaseUrl()),
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type {
|
||||
ApiKey,
|
||||
@@ -57,32 +57,42 @@ const q = (tier: Tier) => `?tier=${encodeURIComponent(tier)}`;
|
||||
export async function fetchDeployments(
|
||||
tier: Tier,
|
||||
): Promise<DeploymentsResponse> {
|
||||
return httpJson<DeploymentsResponse>(
|
||||
return apiClient.local.json<DeploymentsResponse>(
|
||||
`/v1/infrastructure/deployments${q(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /v1/infrastructure/api-keys?tier=… */
|
||||
export async function fetchApiKeys(tier: Tier): Promise<ApiKey[]> {
|
||||
return httpJson<ApiKey[]>(`/v1/infrastructure/api-keys${q(tier)}`);
|
||||
return apiClient.local.json<ApiKey[]>(
|
||||
`/v1/infrastructure/api-keys${q(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /v1/infrastructure/security?tier=… */
|
||||
export async function fetchSecurity(tier: Tier): Promise<SecurityConfig> {
|
||||
return httpJson<SecurityConfig>(`/v1/infrastructure/security${q(tier)}`);
|
||||
return apiClient.local.json<SecurityConfig>(
|
||||
`/v1/infrastructure/security${q(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /v1/infrastructure/models?tier=… */
|
||||
export async function fetchModels(tier: Tier): Promise<ModelsResponse> {
|
||||
return httpJson<ModelsResponse>(`/v1/infrastructure/models${q(tier)}`);
|
||||
return apiClient.local.json<ModelsResponse>(
|
||||
`/v1/infrastructure/models${q(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /v1/infrastructure/storage?tier=… */
|
||||
export async function fetchStorage(tier: Tier): Promise<StorageConfig> {
|
||||
return httpJson<StorageConfig>(`/v1/infrastructure/storage${q(tier)}`);
|
||||
return apiClient.local.json<StorageConfig>(
|
||||
`/v1/infrastructure/storage${q(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /v1/infrastructure/audit-log?tier=… */
|
||||
export async function fetchAuditLog(tier: Tier): Promise<AuditLogResponse> {
|
||||
return httpJson<AuditLogResponse>(`/v1/infrastructure/audit-log${q(tier)}`);
|
||||
return apiClient.local.json<AuditLogResponse>(
|
||||
`/v1/infrastructure/audit-log${q(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { setupServer } from "msw/node";
|
||||
import { linkHandlers } from "@portal/mocks/handlers/link";
|
||||
import { resetLinkStore } from "@portal/mocks/link";
|
||||
|
||||
// Mock the shared Supabase client used by apiClient.saas. The team-wide
|
||||
// /instances + /instances/:id/revoke calls go to SaaS now (auto-attached
|
||||
// Bearer = current Supabase access token). Hoisted so vi.mock can see it.
|
||||
const { getSession } = vi.hoisted(() => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
data: { session: { access_token: "supabase_jwt_test" } },
|
||||
}),
|
||||
}));
|
||||
vi.mock("@shared/auth/supabase/supabaseClient", () => ({
|
||||
getSupabaseClient: () => ({ auth: { getSession } }),
|
||||
configureSupabase: vi.fn(),
|
||||
}));
|
||||
|
||||
// Pretend the SaaS base URL is configured so apiClient.saas calls don't throw
|
||||
// SaasUnconfiguredError. MSW's wildcard handlers (`*/...`) intercept the
|
||||
// absolute URL the same way they do the relative one.
|
||||
vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local");
|
||||
|
||||
import {
|
||||
fetchInstances,
|
||||
fetchStatus,
|
||||
linkInstance,
|
||||
revokeInstance,
|
||||
unlinkInstance,
|
||||
} from "@portal/api/link";
|
||||
|
||||
const server = setupServer(...linkHandlers);
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
beforeEach(() => resetLinkStore());
|
||||
|
||||
describe("api/link — local backend (this instance)", () => {
|
||||
it("starts not-linked", async () => {
|
||||
const status = await fetchStatus();
|
||||
expect(status.linked).toBe(false);
|
||||
});
|
||||
|
||||
it("links this instance via the local endpoint, never returning a secret", async () => {
|
||||
const status = await linkInstance({
|
||||
supabaseJwt: "jwt_abc",
|
||||
name: "node-1",
|
||||
});
|
||||
expect(status.linked).toBe(true);
|
||||
expect(status.name).toBe("node-1");
|
||||
// Contract: the device secret is stored server-side, never sent to the portal.
|
||||
expect(status).not.toHaveProperty("deviceSecret");
|
||||
expect(status).not.toHaveProperty("deviceId");
|
||||
expect(await (await fetchStatus()).linked).toBe(true);
|
||||
});
|
||||
|
||||
it("unlinks this instance", async () => {
|
||||
await linkInstance({ supabaseJwt: "jwt_abc" });
|
||||
// unlink returns 204 (no body); the status is read back separately.
|
||||
await unlinkInstance();
|
||||
expect((await fetchStatus()).linked).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards the SaaS JWT in the link body", async () => {
|
||||
let seenBody: unknown = null;
|
||||
server.events.on("request:start", async ({ request }) => {
|
||||
if (request.method === "POST" && request.url.endsWith("/link")) {
|
||||
seenBody = await request.clone().json();
|
||||
}
|
||||
});
|
||||
await linkInstance({ supabaseJwt: "jwt_xyz", name: "n" });
|
||||
expect(seenBody).toMatchObject({ supabaseJwt: "jwt_xyz" });
|
||||
server.events.removeAllListeners();
|
||||
});
|
||||
});
|
||||
|
||||
describe("api/link — SaaS backend (team-wide)", () => {
|
||||
it("fetches the instance list", async () => {
|
||||
const rows = await fetchInstances();
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows[0]).toHaveProperty("deviceId");
|
||||
});
|
||||
|
||||
it("revokes an instance", async () => {
|
||||
const active = (await fetchInstances()).find((r) => !r.revoked)!;
|
||||
await revokeInstance(active.instanceId);
|
||||
const after = await fetchInstances();
|
||||
expect(after.find((r) => r.instanceId === active.instanceId)?.revoked).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("hits the absolute SaaS URL with the Supabase JWT as Bearer", async () => {
|
||||
let seenUrl: string | null = null;
|
||||
let seenAuth: string | null = null;
|
||||
const capture = ({ request }: { request: Request }) => {
|
||||
if (request.url.includes("/account-link/instances")) {
|
||||
seenUrl = request.url;
|
||||
seenAuth = request.headers.get("authorization");
|
||||
}
|
||||
};
|
||||
server.events.on("request:start", capture);
|
||||
await fetchInstances();
|
||||
expect(seenUrl).toBe(
|
||||
"https://saas.test.local/api/v1/account-link/instances",
|
||||
);
|
||||
expect(seenAuth).toBe("Bearer supabase_jwt_test");
|
||||
server.events.removeAllListeners();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type {
|
||||
LinkInstanceRequest,
|
||||
LinkStatus,
|
||||
LinkedInstanceRow,
|
||||
} from "@portal/mocks/link";
|
||||
|
||||
export type {
|
||||
LinkInstanceRequest,
|
||||
LinkStatus,
|
||||
LinkedInstanceRow,
|
||||
} from "@portal/mocks/link";
|
||||
|
||||
/**
|
||||
* Account-link client (combined-billing "Mode A"). Two distinct surfaces:
|
||||
*
|
||||
* THIS instance — apiClient.local (Spring admin bearer auto-attached):
|
||||
* - POST /api/v1/account-link/link — hand the local backend the admin's
|
||||
* SaaS JWT in the body. It registers
|
||||
* with SaaS + stores the device
|
||||
* secret SERVER-SIDE; the portal
|
||||
* NEVER receives or renders it.
|
||||
* - GET /api/v1/account-link/status — Linked / Not-linked for this
|
||||
* instance.
|
||||
* - POST /api/v1/account-link/unlink — drop this instance's link (local
|
||||
* backend best-effort tells SaaS).
|
||||
*
|
||||
* TEAM-WIDE management — apiClient.saas (admin's Supabase JWT auto-attached
|
||||
* from the in-app account-link login):
|
||||
* - GET /api/v1/account-link/instances — every linked instance
|
||||
* - POST /api/v1/account-link/instances/{id}/revoke
|
||||
*
|
||||
* The team-wide endpoints are served by the hosted SaaS Java backend (the
|
||||
* local backend has no such routes), so they go through apiClient.saas. They're
|
||||
* MSW-intercepted in dev/Storybook via wildcard handlers that match both the
|
||||
* local and absolute SaaS URLs.
|
||||
*/
|
||||
|
||||
const BASE = "/api/v1/account-link";
|
||||
|
||||
/**
|
||||
* Link THIS instance. The local backend takes the SaaS JWT, registers with
|
||||
* SaaS, and persists the device secret itself; the response carries only the
|
||||
* resulting link status. No secret is returned.
|
||||
*/
|
||||
export async function linkInstance(
|
||||
req: LinkInstanceRequest,
|
||||
): Promise<LinkStatus> {
|
||||
return apiClient.local.json<LinkStatus>(`${BASE}/link`, {
|
||||
method: "POST",
|
||||
body: req,
|
||||
});
|
||||
}
|
||||
|
||||
/** Linked / Not-linked for this instance. */
|
||||
export async function fetchStatus(): Promise<LinkStatus> {
|
||||
return apiClient.local.json<LinkStatus>(`${BASE}/status`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop this instance's link. The local backend best-effort tells SaaS to
|
||||
* revoke before clearing the credential locally, then returns 204 — there's no
|
||||
* body, so the caller sets the known unlinked status itself.
|
||||
*/
|
||||
export async function unlinkInstance(): Promise<void> {
|
||||
await apiClient.local.json<void>(`${BASE}/unlink`, { method: "POST" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Every linked instance for the team — SaaS-direct call with the admin's
|
||||
* Supabase JWT (no longer takes an accessToken parameter; the saas client
|
||||
* resolves the live session itself).
|
||||
*/
|
||||
export async function fetchInstances(): Promise<LinkedInstanceRow[]> {
|
||||
return apiClient.saas.json<LinkedInstanceRow[]>(`${BASE}/instances`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a linked instance — SaaS-direct call with the admin's Supabase JWT.
|
||||
*/
|
||||
export async function revokeInstance(instanceId: number): Promise<void> {
|
||||
await apiClient.saas.json<void>(`${BASE}/instances/${instanceId}/revoke`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type {
|
||||
Notification,
|
||||
NotificationCategory,
|
||||
@@ -8,12 +8,12 @@ export type { Notification, NotificationCategory };
|
||||
|
||||
/** GET /v1/notifications */
|
||||
export async function fetchNotifications(): Promise<Notification[]> {
|
||||
return httpJson<Notification[]>("/v1/notifications");
|
||||
return apiClient.local.json<Notification[]>("/v1/notifications");
|
||||
}
|
||||
|
||||
/** POST /v1/notifications/mark-all-read */
|
||||
export async function markAllNotificationsRead(): Promise<void> {
|
||||
await httpJson<{ ok: true }>("/v1/notifications/mark-all-read", {
|
||||
await apiClient.local.json<{ ok: true }>("/v1/notifications/mark-all-read", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { HttpError, httpJson } from "@portal/api/http";
|
||||
import { apiClient, HttpError } from "@portal/api/http";
|
||||
import type { FeaturedOp, OpResultMap } from "@portal/mocks/ops";
|
||||
|
||||
export type { FeaturedOp, OpResultMap };
|
||||
|
||||
/** GET /v1/ops/featured */
|
||||
export async function fetchFeaturedOps(): Promise<FeaturedOp[]> {
|
||||
return httpJson<FeaturedOp[]>("/v1/ops/featured");
|
||||
return apiClient.local.json<FeaturedOp[]>("/v1/ops/featured");
|
||||
}
|
||||
|
||||
export class UnknownOpError extends Error {
|
||||
@@ -21,13 +21,13 @@ export async function runSingleOp(
|
||||
sample: string,
|
||||
): Promise<{ result: OpResultMap; durationMs: number }> {
|
||||
try {
|
||||
return await httpJson<{ result: OpResultMap; durationMs: number }>(
|
||||
`/v1/ops/${encodeURIComponent(opId)}/run`,
|
||||
{
|
||||
method: "POST",
|
||||
body: { sample },
|
||||
},
|
||||
);
|
||||
return await apiClient.local.json<{
|
||||
result: OpResultMap;
|
||||
durationMs: number;
|
||||
}>(`/v1/ops/${encodeURIComponent(opId)}/run`, {
|
||||
method: "POST",
|
||||
body: { sample },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status === 404) {
|
||||
throw new UnknownOpError(opId);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { PipelinesResponse } from "@portal/mocks/pipelines";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
@@ -18,7 +18,7 @@ export type {
|
||||
|
||||
/** GET /v1/pipelines?tier=… — the deployed fleet plus tier-specific extras. */
|
||||
export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> {
|
||||
return httpJson<PipelinesResponse>(
|
||||
return apiClient.local.json<PipelinesResponse>(
|
||||
`/v1/pipelines?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> {
|
||||
* handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted.
|
||||
*/
|
||||
export async function promoteToPolicy(id: string): Promise<{ ok: true }> {
|
||||
return httpJson<{ ok: true }>(
|
||||
return apiClient.local.json<{ ok: true }>(
|
||||
`/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { PoliciesResponse, Policy } from "@portal/mocks/policies";
|
||||
|
||||
/**
|
||||
@@ -45,12 +45,14 @@ export {
|
||||
|
||||
/** GET /api/v1/policies — the catalogue + every configured policy. */
|
||||
export async function fetchPolicies(): Promise<PoliciesResponse> {
|
||||
return httpJson<PoliciesResponse>("/api/v1/policies");
|
||||
return apiClient.local.json<PoliciesResponse>("/api/v1/policies");
|
||||
}
|
||||
|
||||
/** GET /api/v1/policies/{id} — one stored policy's raw record. */
|
||||
export async function fetchPolicy(id: string): Promise<Policy> {
|
||||
return httpJson<Policy>(`/api/v1/policies/${encodeURIComponent(id)}`);
|
||||
return apiClient.local.json<Policy>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,14 +60,20 @@ export async function fetchPolicy(id: string): Promise<Policy> {
|
||||
* assigns owner + team server-side and returns the stored policy with its id.
|
||||
*/
|
||||
export async function savePolicy(policy: Policy): Promise<Policy> {
|
||||
return httpJson<Policy>("/api/v1/policies", { method: "POST", body: policy });
|
||||
return apiClient.local.json<Policy>("/api/v1/policies", {
|
||||
method: "POST",
|
||||
body: policy,
|
||||
});
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/policies/{id} — remove a stored policy. */
|
||||
export async function deletePolicy(id: string): Promise<void> {
|
||||
await httpJson<void>(`/api/v1/policies/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await apiClient.local.json<void>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** The async run acknowledgement: a run id to poll for status. */
|
||||
@@ -83,7 +91,7 @@ export interface PolicyRunResponse {
|
||||
* run id. Runs regardless of the policy's enabled flag.
|
||||
*/
|
||||
export async function runPolicy(id: string): Promise<PolicyRunResponse> {
|
||||
return httpJson<PolicyRunResponse>(
|
||||
return apiClient.local.json<PolicyRunResponse>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}/run`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { ComponentsResponse } from "@portal/mocks/sdkComponents";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
@@ -22,7 +22,7 @@ export {
|
||||
|
||||
/** GET /v1/components?tier=… — summary strip + the embeddable SDK catalogue. */
|
||||
export async function fetchComponents(tier: Tier): Promise<ComponentsResponse> {
|
||||
return httpJson<ComponentsResponse>(
|
||||
return apiClient.local.json<ComponentsResponse>(
|
||||
`/v1/components?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { QuickAction } from "@portal/mocks/search";
|
||||
|
||||
export type { QuickAction };
|
||||
|
||||
/** GET /v1/search/quick-actions */
|
||||
export async function fetchQuickActions(): Promise<QuickAction[]> {
|
||||
return httpJson<QuickAction[]>("/v1/search/quick-actions");
|
||||
return apiClient.local.json<QuickAction[]>("/v1/search/quick-actions");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { SettingsSnapshot } from "@portal/mocks/settings";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
@@ -13,7 +13,7 @@ export type {
|
||||
|
||||
/** GET /v1/settings?tier=… — the account + workspace snapshot the modal edits. */
|
||||
export async function fetchSettings(tier: Tier): Promise<SettingsSnapshot> {
|
||||
return httpJson<SettingsSnapshot>(
|
||||
return apiClient.local.json<SettingsSnapshot>(
|
||||
`/v1/settings?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
|
||||
/**
|
||||
* Sources service layer: the backend contract.
|
||||
@@ -59,22 +59,30 @@ export interface Source {
|
||||
|
||||
/** GET /api/v1/sources: KPI strip + one row per source for the admin. */
|
||||
export async function fetchSources(): Promise<SourcesResponse> {
|
||||
return httpJson<SourcesResponse>("/api/v1/sources");
|
||||
return apiClient.local.json<SourcesResponse>("/api/v1/sources");
|
||||
}
|
||||
|
||||
/** GET /api/v1/sources/{id}: the raw source record (config options), for editing. */
|
||||
export async function fetchSource(id: string): Promise<Source> {
|
||||
return httpJson<Source>(`/api/v1/sources/${encodeURIComponent(id)}`);
|
||||
return apiClient.local.json<Source>(
|
||||
`/api/v1/sources/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** POST /api/v1/sources: create (blank id) or update (matched id) a source. */
|
||||
export async function createSource(source: Source): Promise<Source> {
|
||||
return httpJson<Source>("/api/v1/sources", { method: "POST", body: source });
|
||||
return apiClient.local.json<Source>("/api/v1/sources", {
|
||||
method: "POST",
|
||||
body: source,
|
||||
});
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/sources/{id}: remove a source (409 if a policy references it). */
|
||||
export async function deleteSource(id: string): Promise<void> {
|
||||
await httpJson<void>(`/api/v1/sources/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await apiClient.local.json<void>(
|
||||
`/api/v1/sources/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type {
|
||||
BillingHistoryRow,
|
||||
BillingSummary,
|
||||
PlanOption,
|
||||
UsageSeriesResponse,
|
||||
} from "@portal/mocks/usage";
|
||||
|
||||
export type {
|
||||
BillingHistoryRow,
|
||||
BillingSummary,
|
||||
InvoiceStatus,
|
||||
PlanOption,
|
||||
UsagePoint,
|
||||
UsageSeriesResponse,
|
||||
} from "@portal/mocks/usage";
|
||||
export { OVERAGE_RATE } from "@portal/mocks/usage";
|
||||
|
||||
/** GET /v1/billing/usage — 30-day docs-processed series. */
|
||||
export async function fetchBillingUsage(): Promise<UsageSeriesResponse> {
|
||||
return httpJson<UsageSeriesResponse>("/v1/billing/usage");
|
||||
}
|
||||
|
||||
/** GET /v1/billing/summary?tier=… — KPI strip + current-plan figures. */
|
||||
export async function fetchBillingSummary(tier: Tier): Promise<BillingSummary> {
|
||||
return httpJson<BillingSummary>(
|
||||
`/v1/billing/summary?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /v1/billing/plans — available plan catalogue. */
|
||||
export async function fetchPlanOptions(): Promise<PlanOption[]> {
|
||||
return httpJson<PlanOption[]>("/v1/billing/plans");
|
||||
}
|
||||
|
||||
/** GET /v1/billing/history?tier=… — invoice / line-item history. */
|
||||
export async function fetchBillingHistory(
|
||||
tier: Tier,
|
||||
): Promise<BillingHistoryRow[]> {
|
||||
return httpJson<BillingHistoryRow[]>(
|
||||
`/v1/billing/history?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { UsersResponse } from "@portal/mocks/users";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
@@ -20,5 +20,7 @@ export {
|
||||
|
||||
/** GET /v1/users?tier=… — summary strip, members table, role catalogue, access. */
|
||||
export async function fetchUsers(tier: Tier): Promise<UsersResponse> {
|
||||
return httpJson<UsersResponse>(`/v1/users?tier=${encodeURIComponent(tier)}`);
|
||||
return apiClient.local.json<UsersResponse>(
|
||||
`/v1/users?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
configureSupabase,
|
||||
getSupabaseClient,
|
||||
} from "@shared/auth/supabase/supabaseClient";
|
||||
|
||||
/**
|
||||
* Configures the shared Supabase client against the hosted SaaS project so the
|
||||
* portal can mint a SaaS JWT IN-APP for account linking (no popup). This is a
|
||||
* separate, transient SaaS auth — the portal's own session stays Spring (the
|
||||
* local instance admin); calls to the local backend still carry the Spring
|
||||
* bearer, and the SaaS JWT is passed only in the link request body.
|
||||
*
|
||||
* Config: VITE_SAAS_SUPABASE_URL + VITE_SAAS_SUPABASE_ANON_KEY (both public).
|
||||
* Absent → {@link isSaasSupabaseConfigured} is false and the link UI degrades to
|
||||
* a "configure the SaaS Supabase URL" state.
|
||||
*/
|
||||
const url = import.meta.env.VITE_SAAS_SUPABASE_URL;
|
||||
const key = import.meta.env.VITE_SAAS_SUPABASE_ANON_KEY;
|
||||
|
||||
export const isSaasSupabaseConfigured = Boolean(url && key);
|
||||
|
||||
/** OAuth providers the hosted SaaS login offers (mirrors the SaaS editor login). */
|
||||
export const SAAS_OAUTH_PROVIDERS = ["google", "github", "apple", "azure"];
|
||||
|
||||
/** sessionStorage marker set before an SSO redirect so the return can finish the link. */
|
||||
export const PENDING_LINK_KEY = "stirling-account-link-pending";
|
||||
|
||||
let configured = false;
|
||||
|
||||
/**
|
||||
* Configure the shared Supabase client once (idempotent). Returns the client, or
|
||||
* null when the SaaS Supabase env isn't set. `detectSessionInUrl` (on by default)
|
||||
* means an SSO redirect back to the portal is picked up here.
|
||||
*/
|
||||
export function ensureSaasSupabase() {
|
||||
if (!isSaasSupabaseConfigured) return null;
|
||||
if (!configured) {
|
||||
configureSupabase({ url: url as string, key: key as string });
|
||||
configured = true;
|
||||
}
|
||||
return getSupabaseClient();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// Mock the shared Supabase client the in-app link login is wired to. Declared
|
||||
// via vi.hoisted so the vi.mock factory can reference them.
|
||||
const { signInWithPassword, signInWithOAuth } = vi.hoisted(() => ({
|
||||
signInWithPassword: vi.fn(),
|
||||
signInWithOAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@shared/auth/supabase/supabaseClient", () => ({
|
||||
getSupabaseClient: () => ({ auth: { signInWithPassword, signInWithOAuth } }),
|
||||
}));
|
||||
|
||||
import { useSupabaseLogin } from "@shared/auth/ui/useSupabaseLogin";
|
||||
|
||||
describe("useSupabaseLogin (in-app account-link login)", () => {
|
||||
beforeEach(() => {
|
||||
signInWithPassword.mockReset();
|
||||
signInWithOAuth.mockReset();
|
||||
});
|
||||
|
||||
it("fires onSuccess with the access token on a successful email sign-in", async () => {
|
||||
signInWithPassword.mockResolvedValue({
|
||||
data: { session: { access_token: "tok-123" } },
|
||||
error: null,
|
||||
});
|
||||
const onSuccess = vi.fn();
|
||||
const { result } = renderHook(() => useSupabaseLogin({ onSuccess }));
|
||||
|
||||
act(() => {
|
||||
result.current.setEmail("admin@org.com");
|
||||
result.current.setPassword("pw");
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.signInWithEmail();
|
||||
});
|
||||
|
||||
expect(signInWithPassword).toHaveBeenCalledWith({
|
||||
email: "admin@org.com",
|
||||
password: "pw",
|
||||
});
|
||||
expect(onSuccess).toHaveBeenCalledWith({ access_token: "tok-123" });
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces an email sign-in error and skips onSuccess", async () => {
|
||||
signInWithPassword.mockResolvedValue({
|
||||
data: { session: null },
|
||||
error: { message: "Invalid login credentials" },
|
||||
});
|
||||
const onSuccess = vi.fn();
|
||||
const { result } = renderHook(() => useSupabaseLogin({ onSuccess }));
|
||||
|
||||
act(() => {
|
||||
result.current.setEmail("admin@org.com");
|
||||
result.current.setPassword("nope");
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.signInWithEmail();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("Invalid login credentials");
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("kicks off OAuth with the provider, redirect, and pre-redirect hook", async () => {
|
||||
signInWithOAuth.mockResolvedValue({ data: {}, error: null });
|
||||
const onBeforeOAuth = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useSupabaseLogin({
|
||||
providers: ["google", "github"],
|
||||
redirectTo: "http://portal.local/account-link",
|
||||
onBeforeOAuth,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.hasProviders).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.signInWithProvider("google");
|
||||
});
|
||||
|
||||
expect(onBeforeOAuth).toHaveBeenCalledWith("google");
|
||||
expect(signInWithOAuth).toHaveBeenCalledWith({
|
||||
provider: "google",
|
||||
options: { redirectTo: "http://portal.local/account-link" },
|
||||
});
|
||||
});
|
||||
|
||||
it("validates both fields before calling Supabase", async () => {
|
||||
const { result } = renderHook(() => useSupabaseLogin());
|
||||
await act(async () => {
|
||||
await result.current.signInWithEmail();
|
||||
});
|
||||
expect(signInWithPassword).not.toHaveBeenCalled();
|
||||
expect(result.current.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
currencySymbol,
|
||||
docCapForMoney,
|
||||
formatMinor,
|
||||
formatPeriodDate,
|
||||
meterState,
|
||||
} from "@shared/billing";
|
||||
|
||||
/**
|
||||
* Unit tests for the @shared/billing money/meter helpers the portal billing
|
||||
* surface (and the editor cloud surface) depend on. docCapForMoney mirrors the
|
||||
* backend's cap→PDF conversion and meterState mirrors the BE warn/degrade bands,
|
||||
* so these invariants matter beyond cosmetics.
|
||||
*/
|
||||
describe("docCapForMoney", () => {
|
||||
it("returns null when there is no cap", () => {
|
||||
expect(docCapForMoney(null, 2)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the rate is unresolved or non-positive", () => {
|
||||
expect(docCapForMoney(1000, null)).toBeNull();
|
||||
expect(docCapForMoney(1000, 0)).toBeNull();
|
||||
expect(docCapForMoney(1000, -5)).toBeNull();
|
||||
});
|
||||
|
||||
it("floors capMinor / rate (the backend mirror)", () => {
|
||||
// $1000 cap, 2 minor units / doc → floor(100000 / 2) = 50000 PDFs.
|
||||
expect(docCapForMoney(1000, 2)).toBe(50000);
|
||||
// Sub-cent rate (0.5 minor) → floor(100000 / 0.5) = 200000.
|
||||
expect(docCapForMoney(1000, 0.5)).toBe(200000);
|
||||
// Floors a partial PDF down.
|
||||
expect(docCapForMoney(10, 3)).toBe(333);
|
||||
});
|
||||
|
||||
it("treats a $0 cap as zero paid PDFs", () => {
|
||||
expect(docCapForMoney(0, 2)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("meterState", () => {
|
||||
it("is FULL below the warn band", () => {
|
||||
expect(meterState(10, 100).state).toBe("FULL");
|
||||
expect(meterState(79, 100).state).toBe("FULL");
|
||||
});
|
||||
|
||||
it("is WARNED from 80% up to (not including) 100%", () => {
|
||||
expect(meterState(80, 100).state).toBe("WARNED");
|
||||
expect(meterState(99, 100).state).toBe("WARNED");
|
||||
});
|
||||
|
||||
it("is DEGRADED at and above 100%, with pct clamped to 100", () => {
|
||||
expect(meterState(100, 100)).toEqual({ state: "DEGRADED", pct: 100 });
|
||||
const over = meterState(500, 100);
|
||||
expect(over.state).toBe("DEGRADED");
|
||||
expect(over.pct).toBe(100);
|
||||
});
|
||||
|
||||
it("treats a non-positive limit as fully consumed", () => {
|
||||
expect(meterState(0, 0)).toEqual({ state: "DEGRADED", pct: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("currencySymbol", () => {
|
||||
it("maps known currencies and defaults empty/usd to $", () => {
|
||||
expect(currencySymbol("usd")).toBe("$");
|
||||
expect(currencySymbol("")).toBe("$");
|
||||
expect(currencySymbol(null)).toBe("$");
|
||||
expect(currencySymbol("eur")).toBe("€");
|
||||
expect(currencySymbol("gbp")).toBe("£");
|
||||
});
|
||||
|
||||
it("falls back to the upper-cased code for anything unmapped", () => {
|
||||
expect(currencySymbol("cad")).toBe("CAD ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMinor", () => {
|
||||
it("formats whole and fractional cents", () => {
|
||||
expect(formatMinor(224, "usd")).toContain("2.24");
|
||||
expect(formatMinor(5, "usd")).toContain("0.05");
|
||||
});
|
||||
|
||||
it("keeps up to 3 fraction digits so sub-cent rates don't round to $0", () => {
|
||||
expect(formatMinor(0.5, "usd")).toContain("0.005");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPeriodDate", () => {
|
||||
it("returns an empty string for null", () => {
|
||||
expect(formatPeriodDate(null)).toBe("");
|
||||
});
|
||||
|
||||
it("formats the date part of an ISO string", () => {
|
||||
const out = formatPeriodDate("2026-06-24");
|
||||
expect(out).toContain("Jun");
|
||||
expect(out).toContain("24");
|
||||
});
|
||||
|
||||
it("includes the year only when asked", () => {
|
||||
expect(formatPeriodDate("2026-06-24")).not.toContain("2026");
|
||||
expect(formatPeriodDate("2026-06-24", { year: true })).toContain("2026");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Branch coverage for the Stripe edge-function client: the embedded-checkout vs
|
||||
* already-subscribed-redirect vs neither-secret-nor-url mapping, the mock flag,
|
||||
* unconfigured Supabase, and the portal-session path.
|
||||
*/
|
||||
const { getClient, invoke } = vi.hoisted(() => ({
|
||||
getClient: vi.fn(),
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() }));
|
||||
vi.mock("@shared/auth/supabase/supabaseClient", () => ({
|
||||
getSupabaseClient: () => getClient(),
|
||||
configureSupabase: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
createCheckoutSession,
|
||||
createPortalSession,
|
||||
StripeFunctionError,
|
||||
} from "@portal/billing/stripe";
|
||||
|
||||
const req = { teamId: 1, successUrl: "s", cancelUrl: "c" } as const;
|
||||
|
||||
beforeEach(() => {
|
||||
invoke.mockReset();
|
||||
getClient.mockReset().mockReturnValue({ functions: { invoke } });
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("createCheckoutSession", () => {
|
||||
it("maps embedded checkout (client_secret)", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
data: { success: true, client_secret: "cs_123" },
|
||||
error: null,
|
||||
});
|
||||
const s = await createCheckoutSession(req);
|
||||
expect(s).toEqual({
|
||||
clientSecret: "cs_123",
|
||||
redirectUrl: null,
|
||||
alreadySubscribed: false,
|
||||
mock: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("short-circuits already-subscribed to the portal URL (no client secret)", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
already_subscribed: true,
|
||||
portal_url: "https://portal",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
const s = await createCheckoutSession(req);
|
||||
expect(s.alreadySubscribed).toBe(true);
|
||||
expect(s.redirectUrl).toBe("https://portal");
|
||||
expect(s.clientSecret).toBeNull();
|
||||
});
|
||||
|
||||
it("flags a mock client secret", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
data: { success: true, client_secret: "cs_mock_abc" },
|
||||
error: null,
|
||||
});
|
||||
expect((await createCheckoutSession(req)).mock).toBe(true);
|
||||
});
|
||||
|
||||
it("throws when success is false", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
data: { success: false, error: "no team" },
|
||||
error: null,
|
||||
});
|
||||
await expect(createCheckoutSession(req)).rejects.toBeInstanceOf(
|
||||
StripeFunctionError,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when neither client_secret nor url is returned", async () => {
|
||||
invoke.mockResolvedValue({ data: { success: true }, error: null });
|
||||
await expect(createCheckoutSession(req)).rejects.toThrow(/neither/);
|
||||
});
|
||||
|
||||
it("throws unconfigured when there is no Supabase client", async () => {
|
||||
getClient.mockReturnValue(null);
|
||||
const err = await createCheckoutSession(req).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(StripeFunctionError);
|
||||
expect((err as StripeFunctionError).code).toBe("unconfigured");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPortalSession", () => {
|
||||
it("returns the portal URL", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
data: { success: true, url: "https://billing" },
|
||||
error: null,
|
||||
});
|
||||
expect(await createPortalSession({ teamId: 1, returnUrl: "r" })).toBe(
|
||||
"https://billing",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on a free team (no url)", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
data: { success: false, error: "team_not_subscribed" },
|
||||
error: null,
|
||||
});
|
||||
await expect(
|
||||
createPortalSession({ teamId: 1, returnUrl: "r" }),
|
||||
).rejects.toBeInstanceOf(StripeFunctionError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
|
||||
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
|
||||
|
||||
/**
|
||||
* Stripe checkout + portal sessions, minted via the SaaS Supabase edge
|
||||
* functions (no new Java endpoints). Same pattern the SaaS web app uses for
|
||||
* its Plan page — `supabase.functions.invoke` carries the admin's JWT
|
||||
* automatically, and the edge functions resolve the team via the
|
||||
* `payg_get_checkout_context` RPC.
|
||||
*/
|
||||
|
||||
export class StripeFunctionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "StripeFunctionError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Currencies the SaaS PAYG offering supports. Default for new checkouts is "usd". */
|
||||
export type SaasCurrency = "usd" | "eur" | "gbp";
|
||||
|
||||
interface CheckoutSessionRequest {
|
||||
teamId: number;
|
||||
/** Where Stripe redirects on success — typically the portal billing page. */
|
||||
successUrl: string;
|
||||
/** Where Stripe redirects on cancel/close. */
|
||||
cancelUrl: string;
|
||||
/** ISO 4217 lower-case. Defaults to "usd"; portal uses the wallet's currency when set. */
|
||||
currency?: SaasCurrency;
|
||||
/** Optional prefill for the Stripe Checkout email field. */
|
||||
billingOwnerEmail?: string;
|
||||
}
|
||||
|
||||
interface PortalSessionRequest {
|
||||
teamId: number;
|
||||
returnUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout response shape. The edge function defaults to embedded Stripe
|
||||
* Checkout (returns {@code client_secret}); it can also return:
|
||||
* - {@code portal_url} + {@code already_subscribed: true} when the team is
|
||||
* already on PAYG (short-circuit so the click still does something useful)
|
||||
* - {@code url} alongside or instead of {@code client_secret} for hosted /
|
||||
* redirect-mode flows (rare; embedded is the default for the SaaS UX).
|
||||
*/
|
||||
interface CheckoutResponse {
|
||||
success: boolean;
|
||||
client_secret?: string;
|
||||
url?: string;
|
||||
portal_url?: string;
|
||||
already_subscribed?: boolean;
|
||||
mock?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PortalResponse {
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function invoke<T>(
|
||||
name: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
ensureSaasSupabase();
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) {
|
||||
throw new StripeFunctionError(
|
||||
"SaaS Supabase not configured — set VITE_SAAS_SUPABASE_URL.",
|
||||
"unconfigured",
|
||||
);
|
||||
}
|
||||
const { data, error } = await supabase.functions.invoke<T>(name, { body });
|
||||
if (error) {
|
||||
throw new StripeFunctionError(
|
||||
error.message ?? `Edge function ${name} failed`,
|
||||
);
|
||||
}
|
||||
if (data == null) {
|
||||
throw new StripeFunctionError(`Edge function ${name} returned no data`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of {@link createCheckoutSession}. Exactly ONE of {@code clientSecret}
|
||||
* or {@code redirectUrl} is set: clientSecret drives embedded Stripe Checkout
|
||||
* (the default UX, matching the SaaS web app); redirectUrl is used for the
|
||||
* already-subscribed short-circuit (portal URL) or any hosted-mode fallback.
|
||||
*/
|
||||
export interface CheckoutSession {
|
||||
clientSecret: string | null;
|
||||
redirectUrl: string | null;
|
||||
alreadySubscribed: boolean;
|
||||
mock: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a Stripe Checkout session for PAYG subscription. Defaults to embedded
|
||||
* Checkout (returns {@code clientSecret}) so the portal can mount
|
||||
* <EmbeddedCheckoutProvider> inline. If the team is already subscribed the
|
||||
* edge function short-circuits to a Customer Portal URL — surfaced as
|
||||
* {@code redirectUrl} + {@code alreadySubscribed=true} so the caller can open it
|
||||
* in a new tab instead of trying to mount a checkout iframe with no secret.
|
||||
*/
|
||||
export async function createCheckoutSession(
|
||||
req: CheckoutSessionRequest,
|
||||
): Promise<CheckoutSession> {
|
||||
const res = await invoke<CheckoutResponse>("create-checkout-session", {
|
||||
team_id: req.teamId,
|
||||
currency: req.currency ?? "usd",
|
||||
success_url: req.successUrl,
|
||||
cancel_url: req.cancelUrl,
|
||||
...(req.billingOwnerEmail
|
||||
? { billing_owner_email: req.billingOwnerEmail }
|
||||
: {}),
|
||||
});
|
||||
if (!res.success) {
|
||||
throw new StripeFunctionError(
|
||||
res.error ?? "create-checkout-session failed",
|
||||
);
|
||||
}
|
||||
const alreadySubscribed = Boolean(res.already_subscribed);
|
||||
const redirectUrl = alreadySubscribed
|
||||
? (res.portal_url ?? null)
|
||||
: (res.url ?? null);
|
||||
const clientSecret = alreadySubscribed ? null : (res.client_secret ?? null);
|
||||
if (!clientSecret && !redirectUrl) {
|
||||
throw new StripeFunctionError(
|
||||
"create-checkout-session returned neither client_secret nor URL",
|
||||
);
|
||||
}
|
||||
return {
|
||||
clientSecret,
|
||||
redirectUrl,
|
||||
alreadySubscribed,
|
||||
mock: Boolean(res.mock) || clientSecret?.startsWith("cs_mock_") === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** {@code VITE_STRIPE_PUBLISHABLE_KEY} — the Stripe pk used by embedded Checkout. */
|
||||
export function getStripePublishableKey(): string {
|
||||
return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a Stripe Customer Portal session. The admin can manage their card,
|
||||
* view invoices, and cancel from Stripe's hosted UI. The edge function returns
|
||||
* 404 with {@code team_not_subscribed} if called for a free team — surfaced
|
||||
* here as a StripeFunctionError the caller can toast.
|
||||
*/
|
||||
export async function createPortalSession(
|
||||
req: PortalSessionRequest,
|
||||
): Promise<string> {
|
||||
const res = await invoke<PortalResponse>("create-customer-portal-session", {
|
||||
team_id: req.teamId,
|
||||
return_url: req.returnUrl,
|
||||
});
|
||||
if (!res.success || !res.url) {
|
||||
throw new StripeFunctionError(
|
||||
res.error ?? "create-customer-portal-session failed",
|
||||
);
|
||||
}
|
||||
return res.url;
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
/* Fixed-height shell so the MAIN COLUMN scrolls, not the document. With
|
||||
min-height:100vh the shell grows with content and the document scrolls, which
|
||||
means .portal-shell__view's overflow-y never engages and any `position:sticky`
|
||||
inside it (e.g. a page header) rides away with the document. Pinning the shell
|
||||
to the viewport (+ min-height:0 on the flex descendants so they can shrink
|
||||
below content) makes .portal-shell__view the scroll container, so sticky
|
||||
page headers stick under the global header. */
|
||||
.portal-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
@@ -10,10 +18,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0; /* prevent grid blowout on narrow content */
|
||||
min-height: 0; /* allow the column to bound its children */
|
||||
}
|
||||
|
||||
.portal-shell__view {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0; /* scroll instead of growing past the viewport */
|
||||
overflow-y: auto;
|
||||
animation: fadeInUp var(--motion-enter) both;
|
||||
}
|
||||
|
||||
@@ -40,8 +40,12 @@ function ThemeToggle() {
|
||||
}
|
||||
|
||||
function TierSwitcher() {
|
||||
const { tier, setTier } = useTier();
|
||||
const { tier, setTier, isDerived } = useTier();
|
||||
const info = TIER_INFO[tier];
|
||||
// When mocks are off, the tier is derived from the real link/wallet state —
|
||||
// pair the dropdown with the mocks toggle (hidden in prod) so testing real
|
||||
// billing flows can't be perturbed by accidentally flipping the mock tier.
|
||||
if (isDerived) return null;
|
||||
return (
|
||||
<Dropdown.Root align="end">
|
||||
<Dropdown.Trigger>
|
||||
|
||||
@@ -31,7 +31,9 @@ import {
|
||||
PoliciesIcon,
|
||||
InfrastructureIcon,
|
||||
SparklesIcon,
|
||||
LinkIcon,
|
||||
} from "@portal/components/icons";
|
||||
import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel";
|
||||
import "@portal/components/SettingsModal.css";
|
||||
|
||||
type SettingsSection =
|
||||
@@ -41,7 +43,21 @@ type SettingsSection =
|
||||
| "general"
|
||||
| "authentication"
|
||||
| "sessions"
|
||||
| "early-access";
|
||||
| "early-access"
|
||||
| "account-link";
|
||||
|
||||
function isSettingsSection(value: string | null): value is SettingsSection {
|
||||
return (
|
||||
value === "profile" ||
|
||||
value === "appearance" ||
|
||||
value === "notifications" ||
|
||||
value === "general" ||
|
||||
value === "authentication" ||
|
||||
value === "sessions" ||
|
||||
value === "early-access" ||
|
||||
value === "account-link"
|
||||
);
|
||||
}
|
||||
|
||||
/** Org-wide auth posture the Admin sections edit, mirrored into local state. */
|
||||
interface SecurityForm {
|
||||
@@ -54,6 +70,12 @@ interface SecurityForm {
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Optional section to land on when opening. When `null`/unsupported the modal
|
||||
* picks the default ("profile"). Set by callers like the sidebar's "Link
|
||||
* account" affordance → "account-link".
|
||||
*/
|
||||
initialSection?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +105,11 @@ const SESSION_TIMEOUT_VALUES = ["60", "240", "480", "720", "1440"] as const;
|
||||
* state. Save is a no-op for the demo — it closes — but the theme control
|
||||
* writes straight through to ThemeProvider so the change is real and visible.
|
||||
*/
|
||||
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
export function SettingsModal({
|
||||
open,
|
||||
onClose,
|
||||
initialSection,
|
||||
}: SettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -124,6 +150,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
{
|
||||
title: t("settings.groups.admin"),
|
||||
items: [
|
||||
{
|
||||
key: "account-link",
|
||||
label: t("settings.sections.account-link"),
|
||||
icon: <LinkIcon size={16} />,
|
||||
},
|
||||
{
|
||||
key: "authentication",
|
||||
label: t("settings.sections.authentication"),
|
||||
@@ -189,8 +220,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
}, [snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSection("profile");
|
||||
}, [open]);
|
||||
if (!open) return;
|
||||
const requested = initialSection ?? null;
|
||||
setSection(isSettingsSection(requested) ? requested : "profile");
|
||||
}, [open, initialSection]);
|
||||
|
||||
const regionOptions = useMemo<SelectOption[]>(() => {
|
||||
if (!snapshot) return [];
|
||||
@@ -301,6 +334,8 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "account-link" && <AccountLinkPanel />}
|
||||
</SettingsShell>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useLink } from "@portal/contexts/LinkContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
|
||||
import { EDITOR_URL } from "@portal/auth/editorUrl";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
ComponentsIcon,
|
||||
InfrastructureIcon,
|
||||
UsageIcon,
|
||||
LinkIcon,
|
||||
DocsIcon,
|
||||
SettingsIcon,
|
||||
ChevronDownIcon,
|
||||
@@ -47,6 +49,27 @@ const GROUP_PLATFORM: NavEntry[] = [
|
||||
{ id: "docs", icon: <DocsIcon /> },
|
||||
];
|
||||
|
||||
/**
|
||||
* Sidebar-footer link-account CTA. Only visible when the org is unlinked — once
|
||||
* linked, the linked-instances row + plan badge already communicate the state,
|
||||
* so a permanent footer button would be noise. Click → opens the login modal
|
||||
* directly.
|
||||
*/
|
||||
function LinkAccountFooterItem() {
|
||||
const { t } = useTranslation();
|
||||
const { openLinkModal } = useUI();
|
||||
const { linkState } = useLink();
|
||||
if (linkState !== "unlinked") return null;
|
||||
return (
|
||||
<NavItem
|
||||
id="account-link"
|
||||
label={t("shell.sidebar.linkAccount", "Link Stirling account")}
|
||||
icon={<LinkIcon />}
|
||||
onClick={() => openLinkModal()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageFooter() {
|
||||
const { tier } = useTier();
|
||||
const { t } = useTranslation();
|
||||
@@ -90,8 +113,8 @@ function UsageFooter() {
|
||||
|
||||
const planLabel =
|
||||
tier === "pro"
|
||||
? t("shell.sidebar.planPayAsYouGo")
|
||||
: t("shell.sidebar.planEnterprise");
|
||||
? t("shell.sidebar.planProcessor", "Processor plan")
|
||||
: t("shell.sidebar.planEnterprise", "Enterprise plan");
|
||||
|
||||
return (
|
||||
<div className="portal-sidebar__usage">
|
||||
@@ -200,6 +223,7 @@ export function Sidebar() {
|
||||
</nav>
|
||||
|
||||
<div className="portal-sidebar__footer">
|
||||
<LinkAccountFooterItem />
|
||||
<NavItem
|
||||
id="settings"
|
||||
label={t("nav.settings")}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Skeleton, StatusBadge } from "@shared/components";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { useAccountLinkContext } from "@portal/contexts/AccountLinkContext";
|
||||
import { useLink, LINK_INFO } from "@portal/contexts/LinkContext";
|
||||
import { HttpError } from "@portal/api/http";
|
||||
import {
|
||||
fetchInstances,
|
||||
revokeInstance as apiRevokeInstance,
|
||||
type LinkedInstanceRow,
|
||||
} from "@portal/api/link";
|
||||
import { LinkAccountCard } from "@portal/components/account-link/LinkAccountCard";
|
||||
import { LinkedInstancesTable } from "@portal/components/account-link/LinkedInstancesTable";
|
||||
import "@portal/views/AccountLink.css";
|
||||
|
||||
/**
|
||||
* Account-link surface rendered inside the Settings modal (Admin group). Same
|
||||
* content as the former /account-link view: the LinkAccountCard for THIS
|
||||
* instance + the team-wide LinkedInstancesTable. Lives inline so admins find it
|
||||
* intentionally rather than via a top-level sidebar nav entry.
|
||||
*/
|
||||
export function AccountLinkPanel() {
|
||||
const { t } = useTranslation();
|
||||
const link = useAccountLinkContext();
|
||||
const { linkState } = useLink();
|
||||
|
||||
const linked = link.status?.linked ?? false;
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
// Only fetch the team-wide instance list when THIS instance is linked. When
|
||||
// unlinked, the portal has no team to display — the admin's SaaS session may
|
||||
// still be valid in the browser, but the local instance isn't part of a team
|
||||
// (so showing the team's other instances would be confusing).
|
||||
const instancesState = useAsync<LinkedInstanceRow[]>(
|
||||
() => (linked ? fetchInstances() : Promise.resolve([])),
|
||||
[reloadKey, linked],
|
||||
);
|
||||
|
||||
const [revokingId, setRevokingId] = useState<number | null>(null);
|
||||
const [revokeError, setRevokeError] = useState<string | null>(null);
|
||||
|
||||
const revoke = useCallback(async (instance: LinkedInstanceRow) => {
|
||||
setRevokingId(instance.instanceId);
|
||||
setRevokeError(null);
|
||||
try {
|
||||
await apiRevokeInstance(instance.instanceId);
|
||||
setReloadKey((k) => k + 1);
|
||||
} catch (e) {
|
||||
setRevokeError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="portal-link portal-link--in-settings">
|
||||
<header className="portal-link__header">
|
||||
<div>
|
||||
<p className="portal-link__page-sub">{t("accountLink.panel.sub")}</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
tone={
|
||||
linkState === "linked-subscribed"
|
||||
? "success"
|
||||
: linkState === "linked-free"
|
||||
? "info"
|
||||
: "neutral"
|
||||
}
|
||||
size="md"
|
||||
>
|
||||
{t(LINK_INFO[linkState].labelKey)}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<LinkAccountCard link={link} />
|
||||
|
||||
{linked && (
|
||||
<section className="portal-link__instances">
|
||||
<div className="portal-link__section-head">
|
||||
<h2 className="portal-link__section-title">
|
||||
{t("accountLink.panel.instancesTitle")}
|
||||
</h2>
|
||||
<p className="portal-link__section-sub">
|
||||
{t("accountLink.panel.instancesSub")}
|
||||
</p>
|
||||
</div>
|
||||
{instancesState.loading ? (
|
||||
<div className="portal-link__skeleton" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} height="3rem" />
|
||||
))}
|
||||
</div>
|
||||
) : instancesState.error ? (
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("accountLink.panel.loadError.title")}
|
||||
>
|
||||
{instancesState.error instanceof HttpError &&
|
||||
instancesState.error.status === 403
|
||||
? t("accountLink.panel.loadError.forbidden")
|
||||
: t("accountLink.panel.loadError.generic")}
|
||||
</Banner>
|
||||
) : (
|
||||
<LinkedInstancesTable
|
||||
instances={instancesState.data ?? []}
|
||||
onRevoke={revoke}
|
||||
revokingId={revokingId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{revokeError && (
|
||||
<Banner tone="danger" title={t("accountLink.panel.revokeError")}>
|
||||
{revokeError}
|
||||
</Banner>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { UseAccountLink } from "@portal/hooks/useAccountLink";
|
||||
import { LinkAccountCard } from "@portal/components/account-link/LinkAccountCard";
|
||||
import "@portal/views/AccountLink.css";
|
||||
|
||||
// A no-op UseAccountLink for static stories; overridden per story.
|
||||
const base: UseAccountLink = {
|
||||
loginConfigured: true,
|
||||
status: { linked: false, name: null },
|
||||
phase: "idle",
|
||||
error: null,
|
||||
completeLink: async () => {},
|
||||
unlink: async () => {},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof LinkAccountCard> = {
|
||||
title: "Portal/AccountLink/LinkAccountCard",
|
||||
component: LinkAccountCard,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LinkAccountCard>;
|
||||
|
||||
/** Not linked — the "Link your Stirling account" button opens the login modal. */
|
||||
export const NotLinked: Story = {
|
||||
args: { link: base },
|
||||
};
|
||||
|
||||
/** Linking — login completed, button shows progress while registering. */
|
||||
export const Linking: Story = {
|
||||
args: { link: { ...base, phase: "linking" } },
|
||||
};
|
||||
|
||||
/** Linked — status only; the device secret is never shown. */
|
||||
export const Linked: Story = {
|
||||
args: {
|
||||
link: { ...base, status: { linked: true, name: "prod-eu-gateway" } },
|
||||
},
|
||||
};
|
||||
|
||||
/** SaaS Supabase not configured — explains the in-app dev simulate fallback. */
|
||||
export const Unconfigured: Story = {
|
||||
args: { link: { ...base, loginConfigured: false } },
|
||||
};
|
||||
|
||||
/** Link error surfaced inline. */
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
link: {
|
||||
...base,
|
||||
phase: "error",
|
||||
error: "Couldn't register this instance with the SaaS backend.",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Card, StatusBadge } from "@shared/components";
|
||||
import type { UseAccountLink } from "@portal/hooks/useAccountLink";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
|
||||
interface Props {
|
||||
link: UseAccountLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status + actions for THIS instance's account link. The "Link" button opens
|
||||
* the single top-level login modal (UIContext.openLinkModal) — never a nested
|
||||
* modal. The portal posts the returned JWT to the local backend, which stores
|
||||
* the device secret server-side; the secret is never received or rendered here.
|
||||
*/
|
||||
export function LinkAccountCard({ link }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { openLinkModal } = useUI();
|
||||
const linking = link.phase === "linking";
|
||||
const linked = link.status?.linked ?? false;
|
||||
|
||||
return (
|
||||
<Card padding="loose" className="portal-link__card">
|
||||
<div className="portal-link__card-head">
|
||||
<div>
|
||||
<span className="portal-link__eyebrow">
|
||||
{t("accountLink.card.eyebrow")}
|
||||
</span>
|
||||
<h2 className="portal-link__title">{t("accountLink.card.title")}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={linked ? "success" : "neutral"} size="sm">
|
||||
{linked
|
||||
? t("accountLink.card.linked")
|
||||
: t("accountLink.card.notLinked")}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
{!link.loginConfigured && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title={t("accountLink.card.loginNotConfigured.title")}
|
||||
>
|
||||
{t("accountLink.card.loginNotConfigured.before")}{" "}
|
||||
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
|
||||
{t("accountLink.card.loginNotConfigured.after")}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{link.error && (
|
||||
<Banner tone="danger" title={t("accountLink.card.error.title")}>
|
||||
{link.error}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{linked ? (
|
||||
<div className="portal-link__actions">
|
||||
<span className="portal-link__muted">
|
||||
{link.status?.name
|
||||
? t("accountLink.card.linkedAs", { name: link.status.name })
|
||||
: t("accountLink.card.linkedGeneric")}{" "}
|
||||
{t("accountLink.card.billingNote")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
accent="red"
|
||||
loading={linking}
|
||||
onClick={link.unlink}
|
||||
>
|
||||
{t("accountLink.card.unlink")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="portal-link__actions">
|
||||
<Button loading={linking} onClick={() => openLinkModal()}>
|
||||
{t("accountLink.card.linkButton")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Modal } from "@shared/components";
|
||||
import SupabaseLoginForm from "@shared/auth/ui/SupabaseLoginForm";
|
||||
import {
|
||||
useSupabaseLogin,
|
||||
type SupabaseLoginSession,
|
||||
} from "@shared/auth/ui/useSupabaseLogin";
|
||||
import "@shared/auth/ui/auth-theme.css";
|
||||
import {
|
||||
ensureSaasSupabase,
|
||||
isSaasSupabaseConfigured,
|
||||
PENDING_LINK_KEY,
|
||||
SAAS_OAUTH_PROVIDERS,
|
||||
} from "@portal/auth/saasSupabase";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* "link" registers this instance against the signed-in account; "reauth" only
|
||||
* refreshes an expired SaaS session (the instance is already linked). The mode
|
||||
* is persisted across the OAuth redirect so the SSO-return handler doesn't
|
||||
* re-register on a reauth.
|
||||
*/
|
||||
mode?: "link" | "reauth";
|
||||
/** Called with the SaaS session after a successful sign-in. */
|
||||
onLinked: (session: SupabaseLoginSession) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app account-link login. Signs the admin in to their Stirling (SaaS) account
|
||||
* via the shared Supabase login (SSO + email/password), then hands the resulting
|
||||
* session to the caller to register this instance. No popup; the device secret
|
||||
* never reaches the browser. SSO redirects away and is finished by useAccountLink
|
||||
* on return.
|
||||
*/
|
||||
export function LinkAccountModal({
|
||||
open,
|
||||
onClose,
|
||||
mode = "link",
|
||||
onLinked,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
if (open) ensureSaasSupabase();
|
||||
}, [open]);
|
||||
|
||||
const reauth = mode === "reauth";
|
||||
const login = useSupabaseLogin({
|
||||
providers: SAAS_OAUTH_PROVIDERS,
|
||||
// Return to the current page after SSO; the SSO-return handler in
|
||||
// useAccountLink reads the persisted mode so it links vs. only refreshes.
|
||||
redirectTo: window.location.href,
|
||||
onBeforeOAuth: () => sessionStorage.setItem(PENDING_LINK_KEY, mode),
|
||||
onSuccess: async (session) => {
|
||||
await onLinked(session);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="md"
|
||||
title={
|
||||
reauth
|
||||
? t("accountLink.modal.reauthTitle")
|
||||
: t("accountLink.modal.linkTitle")
|
||||
}
|
||||
subtitle={
|
||||
reauth
|
||||
? t("accountLink.modal.reauthSubtitle")
|
||||
: t("accountLink.modal.linkSubtitle")
|
||||
}
|
||||
>
|
||||
{isSaasSupabaseConfigured ? (
|
||||
<SupabaseLoginForm state={login} />
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title={t("accountLink.modal.loginNotConfigured.title")}
|
||||
>
|
||||
{t("accountLink.modal.loginNotConfigured.before")}{" "}
|
||||
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
|
||||
{t("accountLink.modal.loginNotConfigured.and")}{" "}
|
||||
<code>VITE_SAAS_SUPABASE_ANON_KEY</code>{" "}
|
||||
{t("accountLink.modal.loginNotConfigured.after")}
|
||||
</Banner>
|
||||
{import.meta.env.DEV && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
await onLinked({ access_token: "dev-stub-jwt" });
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("accountLink.modal.simulateSignIn")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Card } from "@shared/components";
|
||||
import { LinkGate } from "@portal/components/account-link/LinkGate";
|
||||
|
||||
const meta: Meta<typeof LinkGate> = {
|
||||
title: "Portal/AccountLink/LinkGate",
|
||||
component: LinkGate,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LinkGate>;
|
||||
|
||||
/**
|
||||
* Gating follows the Link toolbar global: "Unlinked" shows the lock prompt,
|
||||
* any linked state renders the feature.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
feature: "AI extraction",
|
||||
children: (
|
||||
<Card padding="loose">A billable feature, unlocked once linked.</Card>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button } from "@shared/components";
|
||||
import { useLink } from "@portal/contexts/LinkContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
|
||||
interface Props {
|
||||
/** The billable feature — rendered only when the org is linked. */
|
||||
children: ReactNode;
|
||||
/** Feature name for the lock copy, e.g. "AI extraction". */
|
||||
feature?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gates billable features on the account-link state. When the org is unlinked it
|
||||
* renders a "link to unlock" prompt instead of the feature; once linked (free or
|
||||
* subscribed) the children render. Drop this around any surface that should only
|
||||
* work against a linked SaaS wallet.
|
||||
*/
|
||||
export function LinkGate({ children, feature }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { featuresUnlocked } = useLink();
|
||||
const { openLinkModal } = useUI();
|
||||
|
||||
if (featuresUnlocked) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<Banner
|
||||
tone="info"
|
||||
title={
|
||||
feature
|
||||
? t("accountLink.gate.titleFeature", { feature })
|
||||
: t("accountLink.gate.title")
|
||||
}
|
||||
description={t("accountLink.gate.description")}
|
||||
action={
|
||||
<Button size="sm" onClick={() => openLinkModal()}>
|
||||
{t("accountLink.gate.action")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { listInstances, type LinkedInstanceRow } from "@portal/mocks/link";
|
||||
import { LinkedInstancesTable } from "@portal/components/account-link/LinkedInstancesTable";
|
||||
import "@portal/views/AccountLink.css";
|
||||
|
||||
const meta: Meta<typeof LinkedInstancesTable> = {
|
||||
title: "Portal/AccountLink/LinkedInstancesTable",
|
||||
component: LinkedInstancesTable,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LinkedInstancesTable>;
|
||||
|
||||
/** Active + revoked instances; revoke flips status locally. */
|
||||
export const Default: Story = {
|
||||
render: () => {
|
||||
const [rows, setRows] = useState<LinkedInstanceRow[]>(listInstances());
|
||||
return (
|
||||
<LinkedInstancesTable
|
||||
instances={rows}
|
||||
onRevoke={(i) =>
|
||||
setRows((rs) =>
|
||||
rs.map((r) =>
|
||||
r.instanceId === i.instanceId ? { ...r, revoked: true } : r,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { instances: [], onRevoke: () => {} },
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@shared/components";
|
||||
import type { LinkedInstanceRow } from "@portal/api/link";
|
||||
|
||||
interface Props {
|
||||
instances: LinkedInstanceRow[];
|
||||
/** Called when the leader revokes a (non-revoked) instance. */
|
||||
onRevoke: (instance: LinkedInstanceRow) => void;
|
||||
/** instanceId currently being revoked — disables its button + shows progress. */
|
||||
revokingId?: number | null;
|
||||
}
|
||||
|
||||
function relativeTime(iso: string | null, t: TFunction): string {
|
||||
if (!iso) return t("accountLink.instances.time.never");
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.round(diffMs / 60_000);
|
||||
if (mins < 1) return t("accountLink.instances.time.justNow");
|
||||
if (mins < 60)
|
||||
return t("accountLink.instances.time.minutesAgo", { count: mins });
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return t("accountLink.instances.time.hoursAgo", { count: hrs });
|
||||
return t("accountLink.instances.time.daysAgo", {
|
||||
count: Math.round(hrs / 24),
|
||||
});
|
||||
}
|
||||
|
||||
/** List of linked self-hosted instances with a leader-only revoke action. */
|
||||
export function LinkedInstancesTable({
|
||||
instances,
|
||||
onRevoke,
|
||||
revokingId,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const cols: TableColumn<LinkedInstanceRow>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: t("accountLink.instances.columns.instance"),
|
||||
render: (i) => (
|
||||
<div className="portal-link__cell-stack">
|
||||
<span className="portal-link__cell-strong">
|
||||
{i.name ?? t("accountLink.instances.unnamed")}
|
||||
</span>
|
||||
<code className="portal-link__device-id">{i.deviceId}</code>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("accountLink.instances.columns.status"),
|
||||
render: (i) =>
|
||||
i.revoked ? (
|
||||
<StatusBadge tone="danger" size="sm">
|
||||
{t("accountLink.instances.revoked")}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="success" size="sm" pulse>
|
||||
{t("accountLink.instances.active")}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "lastSeen",
|
||||
header: t("accountLink.instances.columns.lastSeen"),
|
||||
render: (i) => (
|
||||
<span className="portal-link__muted">
|
||||
{relativeTime(i.lastSeenAt, t)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "created",
|
||||
header: t("accountLink.instances.columns.linked"),
|
||||
render: (i) => (
|
||||
<span className="portal-link__muted">
|
||||
{relativeTime(i.createdAt, t)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
render: (i) =>
|
||||
i.revoked ? null : (
|
||||
<Button
|
||||
variant="outline"
|
||||
accent="red"
|
||||
size="sm"
|
||||
loading={revokingId === i.instanceId}
|
||||
onClick={() => onRevoke(i)}
|
||||
>
|
||||
{t("accountLink.instances.revoke")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card padding="none">
|
||||
{instances.length === 0 ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("accountLink.instances.empty.title")}
|
||||
description={t("accountLink.instances.empty.description")}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
columns={cols}
|
||||
rows={instances}
|
||||
rowKey={(i) => String(i.instanceId)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { EnterpriseUpsell } from "@portal/components/billing/EnterpriseUpsell";
|
||||
import "@portal/components/billing/billing.css";
|
||||
|
||||
const meta: Meta<typeof EnterpriseUpsell> = {
|
||||
title: "Portal/Billing/EnterpriseUpsell",
|
||||
component: EnterpriseUpsell,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EnterpriseUpsell>;
|
||||
|
||||
/** Standalone card — used on the free and subscribed billing views. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Bare variant — embeds in another card's column (no surface of its own). */
|
||||
export const Bare: Story = { args: { bare: true } };
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@shared/components";
|
||||
|
||||
interface Props {
|
||||
/** Render without the Card wrapper, to embed inside another card's column. */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume-discount / Enterprise upsell, shared by the free and subscribed billing
|
||||
* views. The CTA is intentionally inert until the sales/quote URL is confirmed.
|
||||
*/
|
||||
export function EnterpriseUpsell({ bare = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const body = (
|
||||
<>
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.enterpriseUpsell.eyebrow")}
|
||||
</span>
|
||||
<div className="portal-billing__enterprise-head">
|
||||
<div>
|
||||
<h3 className="portal-billing__section-title">
|
||||
{t("billing.enterpriseUpsell.title")}
|
||||
</h3>
|
||||
<p className="portal-billing__section-sub">
|
||||
{t("billing.enterpriseUpsell.description")}
|
||||
</p>
|
||||
</div>
|
||||
{/* Destination wired when the enterprise/sales URL is confirmed. */}
|
||||
<Button variant="gradient" size="sm" disabled>
|
||||
{t("billing.enterpriseUpsell.cta")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (bare)
|
||||
return <div className="portal-billing__enterprise-bare">{body}</div>;
|
||||
return <Card padding="loose">{body}</Card>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard";
|
||||
import "@portal/components/billing/billing.css";
|
||||
|
||||
const meta: Meta<typeof FreePdfEditorsCard> = {
|
||||
title: "Portal/Billing/FreePdfEditorsCard",
|
||||
component: FreePdfEditorsCard,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FreePdfEditorsCard>;
|
||||
|
||||
/**
|
||||
* The team editor-fleet card. The metrics are SAMPLE data (flagged with the
|
||||
* Preview badge) until the fleet-telemetry endpoint lands in a follow-up PR;
|
||||
* "Invite teammates" is intentionally inert for now.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
MetricCard,
|
||||
MetricStrip,
|
||||
StatusBadge,
|
||||
} from "@shared/components";
|
||||
import GroupsIcon from "@mui/icons-material/GroupsRounded";
|
||||
import PersonAddIcon from "@mui/icons-material/PersonAddAltRounded";
|
||||
|
||||
/**
|
||||
* "Free PDF Editors" team-fleet card. The editors-deployed / active-this-month /
|
||||
* PDFs-edited figures come from a fleet-telemetry endpoint that does not exist
|
||||
* yet (tracked for a follow-up PR), so they are SAMPLE data — flagged with a
|
||||
* Preview badge — and "Invite teammates" is intentionally inert. The layout is
|
||||
* built so the page matches the marketing design; swap the constants for live
|
||||
* values when the endpoint lands.
|
||||
*/
|
||||
const SAMPLE = {
|
||||
editorsDeployed: "6",
|
||||
activeThisMonth: "4",
|
||||
pdfsEdited: "1,240",
|
||||
};
|
||||
|
||||
export function FreePdfEditorsCard() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<div className="portal-billing__fleet-row">
|
||||
<div className="portal-billing__editors-id">
|
||||
<span className="portal-billing__editors-icon" aria-hidden>
|
||||
<GroupsIcon sx={{ fontSize: 26 }} />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="portal-billing__section-title">
|
||||
{t("billing.freeEditors.title")}{" "}
|
||||
<StatusBadge tone="warning" size="sm" showDot={false}>
|
||||
{t("billing.freeEditors.previewBadge")}
|
||||
</StatusBadge>
|
||||
</h3>
|
||||
<p className="portal-billing__section-sub">
|
||||
{t("billing.freeEditors.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<MetricStrip className="portal-billing__fleet-metrics">
|
||||
<MetricCard
|
||||
label={t("billing.freeEditors.editorsDeployed")}
|
||||
value={SAMPLE.editorsDeployed}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("billing.freeEditors.activeThisMonth")}
|
||||
value={SAMPLE.activeThisMonth}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("billing.freeEditors.pdfsEdited")}
|
||||
value={SAMPLE.pdfsEdited}
|
||||
/>
|
||||
<MetricCard label={t("billing.freeEditors.cost")} value="$0" />
|
||||
</MetricStrip>
|
||||
{/* Opens the Users tab with its invite-member modal (via the ?invite param). */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leadingIcon={<PersonAddIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={() => navigate("/users?invite=1")}
|
||||
>
|
||||
{t("billing.freeEditors.inviteTeammates")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FreePlanView } from "@portal/components/billing/FreePlanView";
|
||||
import { freeWallet } from "@portal/components/billing/walletFixtures";
|
||||
import "@portal/components/billing/billing.css";
|
||||
|
||||
const meta: Meta<typeof FreePlanView> = {
|
||||
title: "Portal/Billing/FreePlanView",
|
||||
component: FreePlanView,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FreePlanView>;
|
||||
|
||||
/** Leader — free meter + the "Turn on Processor" CTA (opens embedded checkout on click). */
|
||||
export const Leader: Story = {
|
||||
args: { wallet: freeWallet },
|
||||
};
|
||||
|
||||
/** Member — sees the explainer but not the enable CTA. */
|
||||
export const Member: Story = {
|
||||
args: { wallet: { ...freeWallet, role: "member" } },
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, StatusBadge } from "@shared/components";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
import type { SaasCurrency } from "@portal/billing/stripe";
|
||||
import { WalletMeter } from "@portal/components/billing/WalletMeter";
|
||||
import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard";
|
||||
import { EnterpriseUpsell } from "@portal/components/billing/EnterpriseUpsell";
|
||||
import { StripeCheckoutModal } from "@portal/components/billing/StripeCheckoutModal";
|
||||
|
||||
interface Props {
|
||||
wallet: Wallet;
|
||||
/** Called after checkout completes so the parent refetches the wallet. */
|
||||
onSubscribed?: () => void;
|
||||
}
|
||||
|
||||
function isSaasCurrency(c: string | null): c is SaasCurrency {
|
||||
return c === "usd" || c === "eur" || c === "gbp";
|
||||
}
|
||||
|
||||
/**
|
||||
* Linked, not yet subscribed — the "Editor" current plan. Shows the team's free
|
||||
* editor fleet, the Processor trial meter (with the inline "Switch on the
|
||||
* Processor" CTA → embedded Stripe Checkout), and the Enterprise upsell.
|
||||
*/
|
||||
export function FreePlanView({ wallet, onSubscribed }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [missingTeam, setMissingTeam] = useState<string | null>(null);
|
||||
|
||||
const isLeader = wallet.role === "leader";
|
||||
const currency: SaasCurrency = isSaasCurrency(wallet.currency)
|
||||
? wallet.currency
|
||||
: "usd";
|
||||
|
||||
function openCheckout() {
|
||||
if (wallet.teamId == null) {
|
||||
setMissingTeam(t("billing.freePlan.noTeamResolved"));
|
||||
return;
|
||||
}
|
||||
setMissingTeam(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
const switchOnAction = isLeader ? (
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={openCheckout}
|
||||
disabled={wallet.teamId == null}
|
||||
>
|
||||
{t("billing.freePlan.switchOnProcessor")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="portal-billing__stack">
|
||||
{/* Current plan */}
|
||||
<div className="portal-billing__current-plan">
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.freePlan.currentPlan")}
|
||||
</span>
|
||||
<div className="portal-billing__current-plan-row">
|
||||
<h2 className="portal-billing__current-plan-name">
|
||||
{t("billing.freePlan.planName")}
|
||||
</h2>
|
||||
<StatusBadge tone="success" size="sm" showDot={false}>
|
||||
{t("billing.freePlan.freeForever")}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
{t("billing.freePlan.ssoIncluded")}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone="purple" size="sm" showDot={false}>
|
||||
{t("billing.freePlan.unlimitedUsers")}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FreePdfEditorsCard />
|
||||
|
||||
{/* Processor trial — meter with the inline upgrade CTA */}
|
||||
<WalletMeter wallet={wallet} action={switchOnAction} />
|
||||
|
||||
{missingTeam && (
|
||||
<Banner tone="warning" title={t("billing.freePlan.checkoutErrorTitle")}>
|
||||
{missingTeam}
|
||||
</Banner>
|
||||
)}
|
||||
{!isLeader && (
|
||||
<p className="portal-billing__plan-readonly">
|
||||
{t("billing.freePlan.ownerOnly")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Volume discount / Enterprise */}
|
||||
<EnterpriseUpsell />
|
||||
|
||||
{wallet.teamId != null && (
|
||||
<StripeCheckoutModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
teamId={wallet.teamId}
|
||||
currency={currency}
|
||||
onComplete={() => {
|
||||
setModalOpen(false);
|
||||
onSubscribed?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@shared/components";
|
||||
import { formatMinor, formatPeriodDate } from "@shared/billing";
|
||||
import { fetchInvoices, type Invoice } from "@portal/api/billing";
|
||||
|
||||
const DEFAULT_VISIBLE = 5;
|
||||
/** Fetch a few more than DEFAULT_VISIBLE so "Show more" actually has something to show. */
|
||||
const FETCH_LIMIT = 20;
|
||||
|
||||
function statusTone(
|
||||
status: string,
|
||||
): "success" | "warning" | "danger" | "neutral" {
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return "success";
|
||||
case "open":
|
||||
case "draft":
|
||||
return "warning";
|
||||
case "uncollectible":
|
||||
case "void":
|
||||
return "danger";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent Stripe invoices, sourced from GET /api/v1/payg/invoices (reads
|
||||
* stripe.invoices via the Sync Engine). Backend orders newest first AND
|
||||
* filters out drafts (matching Stripe's own customer portal behavior —
|
||||
* drafts have no public hosted URL or PDF). We fetch FETCH_LIMIT rows and
|
||||
* show DEFAULT_VISIBLE by default with a "Show all N" inline toggle. For
|
||||
* history older than FETCH_LIMIT, the Stripe customer portal (button on the
|
||||
* Subscription card above) is the authoritative archive.
|
||||
*
|
||||
* Each row links straight out to Stripe-hosted assets — the invoice page
|
||||
* ({@code hostedInvoiceUrl}) and the PDF ({@code invoicePdf}). Rendered as
|
||||
* actual {@code <a target="_blank">} anchors rather than {@code window.open}
|
||||
* handlers so they're real user gestures (popup blockers don't trip).
|
||||
*/
|
||||
export function InvoicesList() {
|
||||
const { t } = useTranslation();
|
||||
const [invoices, setInvoices] = useState<Invoice[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
fetchInvoices(FETCH_LIMIT)
|
||||
.then((rows) => {
|
||||
if (!cancelled) setInvoices(rows);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const total = invoices?.length ?? 0;
|
||||
const visible = showAll ? total : Math.min(DEFAULT_VISIBLE, total);
|
||||
const visibleRows = invoices?.slice(0, visible) ?? [];
|
||||
const hasMore = total > DEFAULT_VISIBLE;
|
||||
// We only fetched FETCH_LIMIT rows — at the cap there may be older invoices we
|
||||
// didn't load, so don't claim "all".
|
||||
const atFetchLimit = total >= FETCH_LIMIT;
|
||||
|
||||
// Column layout mirrors Stripe's own customer-portal "Invoice history" rows:
|
||||
// Date · Amount · Status · Description (product name) · Actions
|
||||
// The monospace invoice id is dropped — users care about "what was it for",
|
||||
// not the internal id.
|
||||
const columns: TableColumn<Invoice>[] = [
|
||||
{
|
||||
key: "date",
|
||||
header: t("billing.invoices.columnDate"),
|
||||
render: (inv) =>
|
||||
inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "—",
|
||||
},
|
||||
{
|
||||
key: "pdfs",
|
||||
header: t("billing.invoices.columnPdfsProcessed"),
|
||||
align: "right",
|
||||
// Billed units on the invoice's metered line item; "—" when the
|
||||
// line-item table isn't synced into the Stripe mirror.
|
||||
render: (inv) =>
|
||||
inv.pdfsProcessed == null ? "—" : inv.pdfsProcessed.toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
header: t("billing.invoices.columnAmount"),
|
||||
align: "right",
|
||||
render: (inv) =>
|
||||
inv.totalMinor == null
|
||||
? "—"
|
||||
: formatMinor(inv.totalMinor, inv.currency),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("billing.invoices.columnStatus"),
|
||||
render: (inv) => (
|
||||
<StatusBadge tone={statusTone(inv.status)} size="sm">
|
||||
{inv.status}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
header: t("billing.invoices.columnDescription"),
|
||||
render: (inv) => (
|
||||
<span className="portal-billing__invoice-desc">
|
||||
{inv.description ?? t("billing.invoices.descriptionFallback")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
render: (inv) => (
|
||||
<div className="portal-billing__invoice-actions">
|
||||
{inv.hostedInvoiceUrl && (
|
||||
<a
|
||||
className="portal-billing__invoice-link"
|
||||
href={inv.hostedInvoiceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("billing.invoices.viewAriaLabel", {
|
||||
number: inv.number ?? inv.id,
|
||||
})}
|
||||
>
|
||||
{t("billing.invoices.viewLink")}
|
||||
</a>
|
||||
)}
|
||||
{inv.invoicePdf && (
|
||||
<a
|
||||
className="portal-billing__invoice-link"
|
||||
href={inv.invoicePdf}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("billing.invoices.downloadAriaLabel", {
|
||||
number: inv.number ?? inv.id,
|
||||
})}
|
||||
>
|
||||
{t("billing.invoices.pdfLink")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<h3 className="portal-billing__section-title">
|
||||
{t("billing.invoices.title")}
|
||||
</h3>
|
||||
|
||||
{invoices === null && !error && (
|
||||
<div className="portal-billing__skeleton" aria-hidden>
|
||||
<Skeleton height="2.5rem" />
|
||||
<Skeleton height="2.5rem" />
|
||||
<Skeleton height="2.5rem" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="portal-billing__error" role="alert">
|
||||
{t("billing.invoices.loadError", { error })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{invoices !== null && invoices.length === 0 && !error && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("billing.invoices.emptyTitle")}
|
||||
description={t("billing.invoices.emptyDescription")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{invoices !== null && invoices.length > 0 && (
|
||||
<>
|
||||
<Table
|
||||
className="portal-billing__flush-table"
|
||||
columns={columns}
|
||||
rows={visibleRows}
|
||||
rowKey={(inv) => inv.id}
|
||||
/>
|
||||
{hasMore && (
|
||||
<div className="portal-billing__invoice-footer">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAll((v) => !v)}
|
||||
>
|
||||
{showAll
|
||||
? t("billing.invoices.showFewer", { count: DEFAULT_VISIBLE })
|
||||
: atFetchLimit
|
||||
? t("billing.invoices.showMostRecent", { count: total })
|
||||
: t("billing.invoices.showAll", { count: total })}
|
||||
</Button>
|
||||
{showAll && atFetchLimit && (
|
||||
<span className="portal-billing__invoice-note">
|
||||
{t("billing.invoices.fetchLimitNote", { count: FETCH_LIMIT })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt";
|
||||
import "@portal/components/billing/billing.css";
|
||||
|
||||
const meta: Meta<typeof LinkAccountPrompt> = {
|
||||
title: "Portal/Billing/LinkAccountPrompt",
|
||||
component: LinkAccountPrompt,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LinkAccountPrompt>;
|
||||
|
||||
/** Unlinked billing page — CTA opens the login modal (UIProvider from the preview decorator). */
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card, EmptyState } from "@shared/components";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
|
||||
/**
|
||||
* Unlinked state — the billing page asks the admin to link their Stirling
|
||||
* account to claim the 500-PDF free grant. The CTA opens the login modal
|
||||
* directly (no detour through Settings).
|
||||
*/
|
||||
export function LinkAccountPrompt() {
|
||||
const { t } = useTranslation();
|
||||
const { openLinkModal } = useUI();
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<EmptyState
|
||||
size="default"
|
||||
title={t("billing.linkPrompt.title")}
|
||||
description={t("billing.linkPrompt.description")}
|
||||
actions={
|
||||
<Button variant="gradient" onClick={() => openLinkModal()}>
|
||||
{t("billing.linkPrompt.cta")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { PaymentMethodCard } from "@portal/components/billing/PaymentMethodCard";
|
||||
import "@portal/components/billing/billing.css";
|
||||
|
||||
const meta: Meta<typeof PaymentMethodCard> = {
|
||||
title: "Portal/Billing/PaymentMethodCard",
|
||||
component: PaymentMethodCard,
|
||||
args: { onManage: () => {} },
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PaymentMethodCard>;
|
||||
|
||||
/** Card present in the Stripe mirror. */
|
||||
export const WithCard: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("*/api/v1/payg/payment-method", () =>
|
||||
HttpResponse.json({
|
||||
present: true,
|
||||
brand: "visa",
|
||||
last4: "4242",
|
||||
expMonth: 8,
|
||||
expYear: 2027,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Mirror carries no card (table not synced / no card on file) — neutral fallback. */
|
||||
export const ManagedInStripe: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("*/api/v1/payg/payment-method", () =>
|
||||
HttpResponse.json({
|
||||
present: false,
|
||||
brand: null,
|
||||
last4: null,
|
||||
expMonth: null,
|
||||
expYear: null,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@shared/components";
|
||||
import { fetchPaymentMethod, type PaymentMethod } from "@portal/api/billing";
|
||||
|
||||
interface Props {
|
||||
/** Opens the Stripe customer portal (card changes live in Stripe, not here). */
|
||||
onManage: () => void;
|
||||
managing?: boolean;
|
||||
}
|
||||
|
||||
function titleCase(s: string): string {
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* The team's default card, read from the Stripe mirror via
|
||||
* {@code GET /api/v1/payg/payment-method}. When the mirror doesn't carry the
|
||||
* card (table not synced, or no card on file) we don't invent one — we show a
|
||||
* neutral "managed in Stripe" state. Editing always happens in Stripe's portal;
|
||||
* the Update button just deep-links there.
|
||||
*/
|
||||
export function PaymentMethodCard({ onManage, managing }: Props) {
|
||||
const { t } = useTranslation();
|
||||
// undefined = loading, null = none/unavailable, object = real card.
|
||||
const [pm, setPm] = useState<PaymentMethod | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchPaymentMethod()
|
||||
.then((p) => {
|
||||
if (!cancelled) setPm(p.present ? p : null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPm(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasCard = pm != null && pm.last4 != null;
|
||||
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<div className="portal-billing__subscription-head">
|
||||
<div>
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.paymentMethod.eyebrow")}
|
||||
</span>
|
||||
{hasCard ? (
|
||||
<>
|
||||
<h3 className="portal-billing__section-title">
|
||||
{t("billing.paymentMethod.cardEnding", {
|
||||
brand: titleCase(
|
||||
pm.brand ?? t("billing.paymentMethod.cardFallback"),
|
||||
),
|
||||
last4: pm.last4,
|
||||
})}
|
||||
</h3>
|
||||
<p className="portal-billing__section-sub">
|
||||
{pm.expMonth != null && pm.expYear != null
|
||||
? t("billing.paymentMethod.expiresBilledMonthly", {
|
||||
expiry: `${String(pm.expMonth).padStart(2, "0")}/${pm.expYear}`,
|
||||
})
|
||||
: t("billing.paymentMethod.billedMonthly")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="portal-billing__section-title">
|
||||
{t("billing.paymentMethod.managedTitle")}
|
||||
</h3>
|
||||
<p className="portal-billing__section-sub">
|
||||
{t("billing.paymentMethod.managedSub")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={managing}
|
||||
onClick={onManage}
|
||||
>
|
||||
{t("billing.paymentMethod.update")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PdfsProcessedCard } from "@portal/components/billing/PdfsProcessedCard";
|
||||
import { subscribedWallet } from "@portal/components/billing/walletFixtures";
|
||||
import "@portal/components/billing/billing.css";
|
||||
|
||||
const meta: Meta<typeof PdfsProcessedCard> = {
|
||||
title: "Portal/Billing/PdfsProcessedCard",
|
||||
component: PdfsProcessedCard,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PdfsProcessedCard>;
|
||||
|
||||
/** Metered PDFs split across API / Agents / Automation (real categoryBreakdown). */
|
||||
export const WithBreakdown: Story = { args: { wallet: subscribedWallet } };
|
||||
|
||||
/** Nothing metered yet this period — the split hides. */
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
wallet: {
|
||||
...subscribedWallet,
|
||||
billableUsed: 0,
|
||||
spendUnitsThisPeriod: 0,
|
||||
categoryBreakdown: { api: 0, ai: 0, automation: 0 },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card } from "@shared/components";
|
||||
import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing";
|
||||
|
||||
/**
|
||||
* "PDFs processed this period" headline + a stacked split of where the metered
|
||||
* PDFs went. The split reuses the wallet's existing {@code categoryBreakdown}
|
||||
* (API / Agents / Automation — the same buckets the entitlement service tracks;
|
||||
* the "AI" bucket surfaces as "Agents" here). Real data only: the bar hides when
|
||||
* nothing metered has run yet.
|
||||
*/
|
||||
const SEGMENTS: ReadonlyArray<{
|
||||
key: keyof WalletCategoryBreakdown;
|
||||
labelKey: string;
|
||||
descKey: string;
|
||||
cls: string;
|
||||
}> = [
|
||||
{
|
||||
key: "api",
|
||||
labelKey: "billing.pdfsProcessed.segmentApiLabel",
|
||||
descKey: "billing.pdfsProcessed.segmentApiDesc",
|
||||
cls: "blue",
|
||||
},
|
||||
{
|
||||
key: "ai",
|
||||
labelKey: "billing.pdfsProcessed.segmentAgentsLabel",
|
||||
descKey: "billing.pdfsProcessed.segmentAgentsDesc",
|
||||
cls: "purple",
|
||||
},
|
||||
{
|
||||
key: "automation",
|
||||
labelKey: "billing.pdfsProcessed.segmentAutomationLabel",
|
||||
descKey: "billing.pdfsProcessed.segmentAutomationDesc",
|
||||
cls: "teal",
|
||||
},
|
||||
];
|
||||
|
||||
export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {
|
||||
const { t } = useTranslation();
|
||||
const b = wallet.categoryBreakdown;
|
||||
const total = b.api + b.ai + b.automation;
|
||||
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.pdfsProcessed.eyebrow")}
|
||||
</span>
|
||||
<div className="portal-billing__bignum-row">
|
||||
<span className="portal-billing__bignum">
|
||||
{wallet.billableUsed.toLocaleString()}
|
||||
</span>
|
||||
<span className="portal-billing__bignum-unit">
|
||||
{t("billing.pdfsProcessed.unit")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{total > 0 ? (
|
||||
<>
|
||||
<div
|
||||
className="portal-billing__segbar"
|
||||
role="img"
|
||||
aria-label={t("billing.pdfsProcessed.segbarAriaLabel")}
|
||||
>
|
||||
{SEGMENTS.map((s) =>
|
||||
b[s.key] > 0 ? (
|
||||
<span
|
||||
key={s.key}
|
||||
className={`portal-billing__segbar-seg portal-billing__segbar-seg--${s.cls}`}
|
||||
style={{ width: `${(b[s.key] / total) * 100}%` }}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<div className="portal-billing__seglegend">
|
||||
{SEGMENTS.map((s) => (
|
||||
<div className="portal-billing__seglegend-row" key={s.key}>
|
||||
<span
|
||||
className={`portal-billing__dot portal-billing__dot--${s.cls}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="portal-billing__seglegend-label">
|
||||
{t(s.labelKey)}
|
||||
</span>
|
||||
<span className="portal-billing__seglegend-val">
|
||||
{t("billing.pdfsProcessed.legendValue", {
|
||||
count: b[s.key],
|
||||
formatted: b[s.key].toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
<span className="portal-billing__seglegend-desc">
|
||||
{t(s.descKey)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="portal-billing__section-sub">
|
||||
{t("billing.pdfsProcessed.emptyPeriod")}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SpendLimitCard } from "@portal/components/billing/SpendLimitCard";
|
||||
import { subscribedWallet } from "@portal/components/billing/walletFixtures";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
import "@portal/components/billing/billing.css";
|
||||
|
||||
const meta: Meta<typeof SpendLimitCard> = {
|
||||
title: "Portal/Billing/SpendLimitCard",
|
||||
component: SpendLimitCard,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SpendLimitCard>;
|
||||
|
||||
/** Interactive wrapper so the in-place "Adjust limit" edit toggle works in-story. */
|
||||
function Demo({ wallet, open = false }: { wallet: Wallet; open?: boolean }) {
|
||||
const [adjusting, setAdjusting] = useState(open);
|
||||
return (
|
||||
<SpendLimitCard
|
||||
wallet={wallet}
|
||||
adjusting={adjusting}
|
||||
onAdjustingChange={setAdjusting}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Comfortably within the cap. */
|
||||
export const WithinCap: Story = {
|
||||
render: () => <Demo wallet={subscribedWallet} />,
|
||||
};
|
||||
|
||||
/** Approaching the cap — % used chip + run-rate projection. */
|
||||
export const ApproachingCap: Story = {
|
||||
render: () => (
|
||||
<Demo
|
||||
wallet={{
|
||||
...subscribedWallet,
|
||||
estimatedBillMinor: 85_000,
|
||||
billableUsed: 42_500,
|
||||
spendUnitsThisPeriod: 42_500,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** The in-place editor — buckets + suggested shortcut + guardrail + Save. */
|
||||
export const Editing: Story = {
|
||||
render: () => (
|
||||
<Demo
|
||||
wallet={{
|
||||
...subscribedWallet,
|
||||
estimatedBillMinor: 85_000,
|
||||
billableUsed: 42_500,
|
||||
spendUnitsThisPeriod: 42_500,
|
||||
}}
|
||||
open
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Uncapped — no bar, no projection. */
|
||||
export const NoCap: Story = {
|
||||
render: () => (
|
||||
<Demo wallet={{ ...subscribedWallet, noCap: true, capUsd: null }} />
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Card } from "@shared/components";
|
||||
import {
|
||||
currencySymbol,
|
||||
docCapForMoney,
|
||||
formatMinor,
|
||||
formatMoneyMajor,
|
||||
MeterBar,
|
||||
meterState,
|
||||
SpendCapControl as SharedSpendCapControl,
|
||||
} from "@shared/billing";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
import { updateCap } from "@portal/api/billing";
|
||||
|
||||
interface Props {
|
||||
wallet: Wallet;
|
||||
onWalletChange?: () => void;
|
||||
/** Controlled edit mode — lifted so the over-cap banner can open it. */
|
||||
adjusting: boolean;
|
||||
onAdjustingChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/** Local-date day count between two ISO yyyy-mm-dd strings; 0 if unparseable. */
|
||||
function daysBetween(aIso: string, bIso: string): number {
|
||||
const a = Date.parse(`${aIso}T00:00:00`);
|
||||
const b = Date.parse(`${bIso}T00:00:00`);
|
||||
if (Number.isNaN(a) || Number.isNaN(b)) return 0;
|
||||
return Math.max(0, Math.round((b - a) / 86_400_000));
|
||||
}
|
||||
|
||||
interface Projection {
|
||||
dailyRateMajor: number;
|
||||
daysToCap: number;
|
||||
projectedEndMajor: number;
|
||||
suggestedMajor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Straight-line cap projection from real data: current spend over elapsed days
|
||||
* gives a daily run-rate, extrapolated across the period. Returns null unless
|
||||
* there's a live cap, a known rate, at least a day elapsed, and the trajectory
|
||||
* actually overshoots — i.e. only when there's something to warn about.
|
||||
*/
|
||||
function projectOverspend(wallet: Wallet): Projection | null {
|
||||
if (wallet.noCap || wallet.capUsd == null) return null;
|
||||
if (wallet.estimatedBillMinor == null || wallet.estimatedBillMinor <= 0) {
|
||||
return null;
|
||||
}
|
||||
const totalDays = daysBetween(
|
||||
wallet.billingPeriodStart,
|
||||
wallet.billingPeriodEnd,
|
||||
);
|
||||
if (totalDays <= 0) return null;
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const rawElapsed = daysBetween(wallet.billingPeriodStart, todayIso);
|
||||
// Need at least a full day of data to extrapolate. On day 0 (or clock skew / a
|
||||
// UTC-vs-local boundary) rawElapsed is <= 0; projecting then would treat the whole
|
||||
// period's spend as one day's run-rate and falsely "project to exceed".
|
||||
if (rawElapsed < 1) return null;
|
||||
const elapsed = Math.min(totalDays, rawElapsed);
|
||||
|
||||
const spentMajor = wallet.estimatedBillMinor / 100;
|
||||
const dailyRateMajor = spentMajor / elapsed;
|
||||
const projectedEndMajor = dailyRateMajor * totalDays;
|
||||
if (projectedEndMajor <= wallet.capUsd) return null;
|
||||
|
||||
const daysToCap = Math.max(
|
||||
1,
|
||||
Math.ceil((wallet.capUsd - spentMajor) / dailyRateMajor),
|
||||
);
|
||||
const suggestedMajor = Math.ceil((projectedEndMajor * 1.15) / 1000) * 1000;
|
||||
return { dailyRateMajor, daysToCap, projectedEndMajor, suggestedMajor };
|
||||
}
|
||||
|
||||
function persistedCapOf(wallet: Wallet): number | null {
|
||||
return wallet.noCap ? null : (wallet.capUsd ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cap surface (right card of the spend row). Two in-place modes — display
|
||||
* (flat spend-vs-cap meter via the shared {@link MeterBar}, % used, projection)
|
||||
* and edit (the shared bucket {@link SharedSpendCapControl} + suggested-value
|
||||
* shortcut + guardrail note + Cancel/Save) — swapping in place rather than
|
||||
* revealing a second card. Leader-only edit; members see display only.
|
||||
*/
|
||||
export function SpendLimitCard({
|
||||
wallet,
|
||||
onWalletChange,
|
||||
adjusting,
|
||||
onAdjustingChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const isLeader = wallet.role === "leader";
|
||||
const symbol = currencySymbol(wallet.currency);
|
||||
const persistedCap = persistedCapOf(wallet);
|
||||
const proj = projectOverspend(wallet);
|
||||
|
||||
const [draftCap, setDraftCap] = useState<number | null>(persistedCap);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reseed the draft whenever the editor opens or the persisted value changes.
|
||||
useEffect(() => {
|
||||
if (adjusting) {
|
||||
setDraftCap(persistedCap);
|
||||
setError(null);
|
||||
}
|
||||
}, [adjusting, persistedCap]);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateCap(draftCap);
|
||||
onAdjustingChange(false);
|
||||
onWalletChange?.();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit mode ───────────────────────────────────────────────────────────
|
||||
if (isLeader && adjusting) {
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.spendLimit.eyebrow")}
|
||||
</span>
|
||||
<h3 className="portal-billing__section-title">
|
||||
{t("billing.spendLimit.editTitle")}
|
||||
</h3>
|
||||
|
||||
<SharedSpendCapControl
|
||||
capUsd={draftCap}
|
||||
onChange={setDraftCap}
|
||||
pricePerDocMinor={wallet.pricePerDocMinor}
|
||||
currency={wallet.currency}
|
||||
note={t("billing.spendLimit.capControlNote")}
|
||||
/>
|
||||
|
||||
{proj && draftCap !== proj.suggestedMajor && (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-billing__suggested"
|
||||
onClick={() => setDraftCap(proj.suggestedMajor)}
|
||||
>
|
||||
{t("billing.spendLimit.useSuggested", {
|
||||
amount: formatMoneyMajor(proj.suggestedMajor, wallet.currency),
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="portal-billing__guardrail">
|
||||
<strong>{t("billing.spendLimit.guardrailLabel")}</strong>{" "}
|
||||
{t("billing.spendLimit.guardrailBody")}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Banner tone="danger" title={t("billing.spendLimit.saveError")}>
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<div className="portal-billing__edit-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAdjustingChange(false)}
|
||||
>
|
||||
{t("billing.spendLimit.cancel")}
|
||||
</Button>
|
||||
<Button variant="gradient" size="sm" loading={saving} onClick={save}>
|
||||
{t("billing.spendLimit.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Display mode ────────────────────────────────────────────────────────
|
||||
const spentMinor = wallet.estimatedBillMinor ?? 0;
|
||||
const cap = wallet.capUsd ?? 0;
|
||||
const capActive = !wallet.noCap && wallet.capUsd != null;
|
||||
const { state, pct } = meterState(spentMinor / 100, cap);
|
||||
const remainingMinor = Math.max(0, Math.round(cap * 100) - spentMinor);
|
||||
const docEstimate = docCapForMoney(wallet.capUsd, wallet.pricePerDocMinor);
|
||||
const spentLabel = formatMinor(spentMinor, wallet.currency);
|
||||
|
||||
return (
|
||||
<Card padding="loose" id="portal-spend-limit">
|
||||
<div className="portal-billing__subscription-head">
|
||||
<div>
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.spendLimit.eyebrow")}
|
||||
</span>
|
||||
<p className="portal-billing__section-sub">
|
||||
{t("billing.spendLimit.displaySub")}
|
||||
</p>
|
||||
</div>
|
||||
{isLeader && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAdjustingChange(true)}
|
||||
>
|
||||
{t("billing.spendLimit.adjustLimit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="portal-billing__spend-meter">
|
||||
<MeterBar
|
||||
state={capActive ? state : "FULL"}
|
||||
pct={pct}
|
||||
figure={
|
||||
capActive ? formatMoneyMajor(cap, wallet.currency) : spentLabel
|
||||
}
|
||||
capSuffix={
|
||||
capActive
|
||||
? docEstimate != null
|
||||
? t("billing.spendLimit.capSuffixWithDocs", {
|
||||
documents: docEstimate.toLocaleString(),
|
||||
})
|
||||
: t("billing.spendLimit.capSuffix")
|
||||
: t("billing.spendLimit.noCap")
|
||||
}
|
||||
statusLabel={
|
||||
capActive
|
||||
? t("billing.spendLimit.pctUsed", { pct: Math.round(pct) })
|
||||
: null
|
||||
}
|
||||
showBar={capActive}
|
||||
meta={
|
||||
capActive ? (
|
||||
<>
|
||||
<span>
|
||||
{t("billing.spendLimit.usedThisMonth", {
|
||||
amount: spentLabel,
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("billing.spendLimit.remaining", {
|
||||
amount: formatMinor(remainingMinor, wallet.currency),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
{t("billing.spendLimit.thisPeriodUncapped", {
|
||||
amount: spentLabel,
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{proj && (
|
||||
<p className="portal-billing__projection">
|
||||
<strong>{t("billing.spendLimit.projection.label")}</strong>{" "}
|
||||
{t("billing.spendLimit.projection.body", {
|
||||
count: proj.daysToCap,
|
||||
rate: `${symbol}${proj.dailyRateMajor.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}`,
|
||||
monthEnd: formatMoneyMajor(
|
||||
Math.round(proj.projectedEndMajor),
|
||||
wallet.currency,
|
||||
),
|
||||
suggested: formatMoneyMajor(proj.suggestedMajor, wallet.currency),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SpendThisMonthCard } from "@portal/components/billing/SpendThisMonthCard";
|
||||
import { subscribedWallet } from "@portal/components/billing/walletFixtures";
|
||||
import "@portal/components/billing/billing.css";
|
||||
|
||||
const meta: Meta<typeof SpendThisMonthCard> = {
|
||||
title: "Portal/Billing/SpendThisMonthCard",
|
||||
component: SpendThisMonthCard,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SpendThisMonthCard>;
|
||||
|
||||
/** Actual spend + the Enterprise upsell tacked onto the foot. */
|
||||
export const Default: Story = { args: { wallet: subscribedWallet } };
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card } from "@shared/components";
|
||||
import { formatMinor } from "@shared/billing";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
import { EnterpriseUpsell } from "@portal/components/billing/EnterpriseUpsell";
|
||||
|
||||
/**
|
||||
* Actual metered spend this period, with the Enterprise upsell tacked onto the
|
||||
* foot (matching marketing — not a separate full-width row). Flex column so the
|
||||
* upsell pins to the bottom and the card matches the spend-limit card's height.
|
||||
*/
|
||||
export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) {
|
||||
const { t } = useTranslation();
|
||||
const rateLabel =
|
||||
wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0
|
||||
? formatMinor(wallet.pricePerDocMinor, wallet.currency)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card padding="loose" className="portal-billing__spend-this-month">
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.spendThisMonth.eyebrow")}
|
||||
</span>
|
||||
<div className="portal-billing__bignum-row">
|
||||
<span className="portal-billing__bignum">
|
||||
{formatMinor(wallet.estimatedBillMinor ?? 0, wallet.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="portal-billing__section-sub">
|
||||
{rateLabel
|
||||
? t("billing.spendThisMonth.processedWithRate", {
|
||||
count: wallet.billableUsed,
|
||||
formattedCount: wallet.billableUsed.toLocaleString(),
|
||||
rate: rateLabel,
|
||||
})
|
||||
: t("billing.spendThisMonth.processed", {
|
||||
count: wallet.billableUsed,
|
||||
formattedCount: wallet.billableUsed.toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="portal-billing__spend-foot">
|
||||
<EnterpriseUpsell bare />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Modal, Skeleton } from "@shared/components";
|
||||
import {
|
||||
EmbeddedCheckout,
|
||||
EmbeddedCheckoutProvider,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import type { Stripe } from "@stripe/stripe-js";
|
||||
import {
|
||||
createCheckoutSession,
|
||||
getStripePublishableKey,
|
||||
type SaasCurrency,
|
||||
} from "@portal/billing/stripe";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Caller's resolved team id. The edge function needs it to scope checkout. */
|
||||
teamId: number;
|
||||
/** "usd" | "eur" | "gbp" — the SaaS PAYG offering's supported set. */
|
||||
currency: SaasCurrency;
|
||||
/** Optional billing email prefill (Stripe locks the field when set). */
|
||||
billingOwnerEmail?: string;
|
||||
/**
|
||||
* Fired when Stripe (or the mock continue button) signals success. Caller
|
||||
* refreshes the wallet so the linked-subscribed view takes over.
|
||||
*/
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedded Stripe Checkout, matching the SaaS web app's PAYG sign-up UX. We
|
||||
* fetch a {@code client_secret} from the SaaS Supabase edge function then
|
||||
* mount <EmbeddedCheckoutProvider> inline — no full-page redirect, the
|
||||
* admin stays in the portal.
|
||||
*
|
||||
* If the team is already subscribed the edge function short-circuits to a
|
||||
* Stripe Customer Portal URL; we open it in a new tab and close the modal.
|
||||
*/
|
||||
let stripePromise: Promise<Stripe | null> | null = null;
|
||||
function loadStripeOnce(pk: string): Promise<Stripe | null> {
|
||||
if (stripePromise === null) {
|
||||
stripePromise = import("@stripe/stripe-js").then((m) => m.loadStripe(pk));
|
||||
}
|
||||
return stripePromise;
|
||||
}
|
||||
|
||||
export function StripeCheckoutModal({
|
||||
open,
|
||||
onClose,
|
||||
teamId,
|
||||
currency,
|
||||
billingOwnerEmail,
|
||||
onComplete,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const publishableKey = getStripePublishableKey();
|
||||
|
||||
// Mint the checkout session whenever the modal opens for a fresh team/currency.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Reset on close so re-opening fetches a fresh session.
|
||||
setClientSecret(null);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
createCheckoutSession({
|
||||
teamId,
|
||||
currency,
|
||||
successUrl: window.location.href,
|
||||
cancelUrl: window.location.href,
|
||||
billingOwnerEmail,
|
||||
})
|
||||
.then((session) => {
|
||||
if (cancelled) return;
|
||||
if (session.alreadySubscribed && session.redirectUrl) {
|
||||
// Team's already on PAYG — bounce them to the management portal
|
||||
// instead of mounting a checkout iframe with no secret.
|
||||
window.open(session.redirectUrl, "_blank", "noopener,noreferrer");
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (!session.clientSecret) {
|
||||
setError(t("billing.checkout.noClientSecret"));
|
||||
return;
|
||||
}
|
||||
setClientSecret(session.clientSecret);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, teamId, currency, billingOwnerEmail, onClose, t]);
|
||||
|
||||
const stripe = publishableKey ? loadStripeOnce(publishableKey) : null;
|
||||
const canRender = Boolean(stripe && clientSecret);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="lg"
|
||||
title={t("billing.checkout.title")}
|
||||
subtitle={t("billing.checkout.subtitle")}
|
||||
>
|
||||
{!publishableKey && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title={t("billing.checkout.notConfigured.title")}
|
||||
>
|
||||
{t("billing.checkout.notConfigured.bodyBefore")}{" "}
|
||||
<code>VITE_STRIPE_PUBLISHABLE_KEY</code>{" "}
|
||||
{t("billing.checkout.notConfigured.bodyAfter")}
|
||||
</Banner>
|
||||
)}
|
||||
{publishableKey && error && (
|
||||
<Banner tone="danger" title={t("billing.checkout.error.title")}>
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
{publishableKey && loading && !error && (
|
||||
<div className="portal-billing__skeleton" aria-hidden>
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="18rem" />
|
||||
</div>
|
||||
)}
|
||||
{publishableKey && canRender && stripe && clientSecret && (
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={stripe}
|
||||
options={{ clientSecret, onComplete }}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { SubscribedPlanView } from "@portal/components/billing/SubscribedPlanView";
|
||||
import { subscribedWallet } from "@portal/components/billing/walletFixtures";
|
||||
import "@portal/components/billing/billing.css";
|
||||
|
||||
const card = http.get("*/api/v1/payg/payment-method", () =>
|
||||
HttpResponse.json({
|
||||
present: true,
|
||||
brand: "visa",
|
||||
last4: "4242",
|
||||
expMonth: 8,
|
||||
expYear: 2027,
|
||||
}),
|
||||
);
|
||||
|
||||
const invoices = http.get("*/api/v1/payg/invoices", () =>
|
||||
HttpResponse.json([
|
||||
{
|
||||
id: "in_6",
|
||||
number: "INV-2026-006",
|
||||
status: "open",
|
||||
totalMinor: 714235,
|
||||
currency: "usd",
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
periodStart: "2026-06-01T00:00:00Z",
|
||||
periodEnd: "2026-06-30T00:00:00Z",
|
||||
hostedInvoiceUrl: "https://invoice.stripe.com/i/test_6",
|
||||
invoicePdf: "https://invoice.stripe.com/i/test_6/pdf",
|
||||
description: "Stirling Processor Plan",
|
||||
pdfsProcessed: 142847,
|
||||
},
|
||||
{
|
||||
id: "in_5",
|
||||
number: "INV-2026-005",
|
||||
status: "paid",
|
||||
totalMinor: 691085,
|
||||
currency: "usd",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
periodStart: "2026-05-01T00:00:00Z",
|
||||
periodEnd: "2026-05-31T00:00:00Z",
|
||||
hostedInvoiceUrl: "https://invoice.stripe.com/i/test_5",
|
||||
invoicePdf: "https://invoice.stripe.com/i/test_5/pdf",
|
||||
description: "Stirling Processor Plan",
|
||||
pdfsProcessed: 138217,
|
||||
},
|
||||
{
|
||||
id: "in_4",
|
||||
number: "INV-2026-004",
|
||||
status: "paid",
|
||||
totalMinor: 650515,
|
||||
currency: "usd",
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
periodStart: "2026-04-01T00:00:00Z",
|
||||
periodEnd: "2026-04-30T00:00:00Z",
|
||||
hostedInvoiceUrl: "https://invoice.stripe.com/i/test_4",
|
||||
invoicePdf: "https://invoice.stripe.com/i/test_4/pdf",
|
||||
description: "Stirling Processor Plan",
|
||||
pdfsProcessed: 130103,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const meta: Meta<typeof SubscribedPlanView> = {
|
||||
title: "Portal/Billing/SubscribedPlanView",
|
||||
component: SubscribedPlanView,
|
||||
parameters: { layout: "padded", msw: { handlers: [card, invoices] } },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SubscribedPlanView>;
|
||||
|
||||
/** The full Processor-plan dashboard — leader, within cap. */
|
||||
export const Leader: Story = { args: { wallet: subscribedWallet } };
|
||||
|
||||
/** Approaching the cap — surfaces the over-cap warning banner + projection. */
|
||||
export const ApproachingCap: Story = {
|
||||
args: {
|
||||
wallet: {
|
||||
...subscribedWallet,
|
||||
estimatedBillMinor: 85_000,
|
||||
billableUsed: 42_500,
|
||||
spendUnitsThisPeriod: 42_500,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button } from "@shared/components";
|
||||
import { meterState } from "@shared/billing";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
import { useStripePortal } from "@portal/hooks/useStripePortal";
|
||||
import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard";
|
||||
import { PdfsProcessedCard } from "@portal/components/billing/PdfsProcessedCard";
|
||||
import { SpendThisMonthCard } from "@portal/components/billing/SpendThisMonthCard";
|
||||
import { SpendLimitCard } from "@portal/components/billing/SpendLimitCard";
|
||||
import { PaymentMethodCard } from "@portal/components/billing/PaymentMethodCard";
|
||||
import { InvoicesList } from "@portal/components/billing/InvoicesList";
|
||||
|
||||
interface Props {
|
||||
wallet: Wallet;
|
||||
onWalletChange?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linked + subscribed — the full Processor-plan dashboard, matching the
|
||||
* marketing layout and reusing the free view's building blocks:
|
||||
* - team editor fleet ({@link FreePdfEditorsCard}, shared with the free view)
|
||||
* - PDFs processed + category split ({@link PdfsProcessedCard})
|
||||
* - spend-vs-cap meter, projection, and the leader-only cap editor
|
||||
* ({@link SpendLimitCard} → shared {@code SpendCapControl})
|
||||
* - Enterprise upsell ({@link EnterpriseUpsell}, shared with the free view)
|
||||
* - per-member usage, Stripe invoices, and the default payment method
|
||||
*
|
||||
* Card / subscription management lives in Stripe's hosted portal — both the
|
||||
* page-header "Manage Payment" action and the payment card's "Update" button
|
||||
* deep-link there via {@link useStripePortal}.
|
||||
*/
|
||||
export function SubscribedPlanView({ wallet, onWalletChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [adjusting, setAdjusting] = useState(false);
|
||||
const portal = useStripePortal(wallet);
|
||||
|
||||
const isLeader = wallet.role === "leader";
|
||||
const spent =
|
||||
wallet.estimatedBillMinor != null ? wallet.estimatedBillMinor / 100 : 0;
|
||||
const capActive = !wallet.noCap && wallet.capUsd != null;
|
||||
const { state, pct } = meterState(spent, wallet.capUsd ?? 0);
|
||||
const showCapWarn = capActive && state !== "FULL";
|
||||
|
||||
function raiseLimit() {
|
||||
setAdjusting(true);
|
||||
document
|
||||
.getElementById("portal-spend-limit")
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-billing__stack">
|
||||
{showCapWarn && (
|
||||
<Banner
|
||||
tone={state === "DEGRADED" ? "danger" : "warning"}
|
||||
title={
|
||||
state === "DEGRADED"
|
||||
? t("billing.subscribedPlan.capWarn.reachedTitle")
|
||||
: t("billing.subscribedPlan.capWarn.approachingTitle", {
|
||||
pct: Math.round(pct),
|
||||
})
|
||||
}
|
||||
action={
|
||||
isLeader ? (
|
||||
<Button size="sm" onClick={raiseLimit}>
|
||||
{t("billing.subscribedPlan.capWarn.raiseLimit")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{state === "DEGRADED"
|
||||
? t("billing.subscribedPlan.capWarn.reachedBody")
|
||||
: t("billing.subscribedPlan.capWarn.approachingBody")}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<FreePdfEditorsCard />
|
||||
|
||||
<PdfsProcessedCard wallet={wallet} />
|
||||
|
||||
<div className="portal-billing__spend-row">
|
||||
<SpendThisMonthCard wallet={wallet} />
|
||||
<SpendLimitCard
|
||||
wallet={wallet}
|
||||
onWalletChange={onWalletChange}
|
||||
adjusting={adjusting}
|
||||
onAdjustingChange={setAdjusting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InvoicesList />
|
||||
|
||||
<PaymentMethodCard onManage={portal.open} managing={portal.opening} />
|
||||
|
||||
{portal.error && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("billing.subscribedPlan.portalError.title")}
|
||||
>
|
||||
{portal.error}
|
||||
</Banner>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { WalletMeter } from "@portal/components/billing/WalletMeter";
|
||||
import { freeWallet } from "@portal/components/billing/walletFixtures";
|
||||
import "@portal/components/billing/billing.css";
|
||||
|
||||
const meta: Meta<typeof WalletMeter> = {
|
||||
title: "Portal/Billing/WalletMeter",
|
||||
component: WalletMeter,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WalletMeter>;
|
||||
|
||||
/** Free grant, plenty left (< 80% used) → FULL band. */
|
||||
export const FreePlentyLeft: Story = {
|
||||
// The band keys off used/allowance — 120/500 = 24% → FULL.
|
||||
args: { wallet: { ...freeWallet, billableUsed: 120, freeRemaining: 380 } },
|
||||
};
|
||||
|
||||
/** Free grant approaching the limit (≥ 80%) → WARNED band. */
|
||||
export const FreeApproachingLimit: Story = {
|
||||
args: { wallet: { ...freeWallet, billableUsed: 440, freeRemaining: 60 } },
|
||||
};
|
||||
|
||||
/** Free grant exhausted → DEGRADED band. */
|
||||
export const FreeLimitReached: Story = {
|
||||
args: { wallet: { ...freeWallet, billableUsed: 500, freeRemaining: 0 } },
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card } from "@shared/components";
|
||||
import { formatMinor, MeterBar, meterState } from "@shared/billing";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
|
||||
interface Props {
|
||||
/** A linked-free wallet. */
|
||||
wallet: Wallet;
|
||||
/** Optional top-right action (e.g. "Switch on the Processor"). */
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* cloud plan page). The subscribed spend-vs-cap meter is a separate surface
|
||||
* ({@code SpendLimitCard}); this card is only the free face.
|
||||
*/
|
||||
export function WalletMeter({ wallet, action }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { state, pct } = meterState(wallet.billableUsed, wallet.freeAllowance);
|
||||
const rate =
|
||||
wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0
|
||||
? wallet.pricePerDocMinor
|
||||
: null;
|
||||
const title =
|
||||
rate != null
|
||||
? t("billing.walletMeter.titleWithRate", {
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
rate: formatMinor(rate, wallet.currency),
|
||||
})
|
||||
: t("billing.walletMeter.title", {
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<div className="portal-billing__subscription-head">
|
||||
<div>
|
||||
<span className="portal-billing__eyebrow">
|
||||
{t("billing.walletMeter.eyebrow")}
|
||||
</span>
|
||||
<h2 className="portal-billing__meter-title">{title}</h2>
|
||||
<p className="portal-billing__section-sub">
|
||||
{t("billing.walletMeter.sub")}
|
||||
</p>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
<div className="portal-billing__trial-meter">
|
||||
<MeterBar
|
||||
state={state}
|
||||
pct={pct}
|
||||
figure={wallet.billableUsed.toLocaleString()}
|
||||
capSuffix={t("billing.walletMeter.capSuffix", {
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
})}
|
||||
statusLabel={t("billing.walletMeter.statusLabel", {
|
||||
count: wallet.freeRemaining,
|
||||
remaining: wallet.freeRemaining.toLocaleString(),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
/* Portal billing page — paired with views/Usage.tsx (.portal-billing root). */
|
||||
|
||||
.portal-billing__stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Spend this month | Spend limit — two separate cards, side by side. */
|
||||
.portal-billing__spend-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
|
||||
gap: 0.75rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
@media (max-width: 60rem) {
|
||||
.portal-billing__spend-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Left card is a flex column so the Enterprise upsell pins to the foot and the
|
||||
card matches the spend-limit card's height. */
|
||||
.portal-billing__spend-this-month {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.portal-billing__spend-foot {
|
||||
margin-top: auto;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.portal-billing__spend-foot:not(:first-child) {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
/* Cap editor (in-place, replaces the spend-limit display). */
|
||||
.portal-billing__suggested {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-blue);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.portal-billing__suggested:hover {
|
||||
border-color: var(--color-blue);
|
||||
}
|
||||
.portal-billing__guardrail {
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
.portal-billing__guardrail strong {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.portal-billing__edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
/* Tighten the billing cards to the marketing rhythm — less empty top/bottom and
|
||||
slightly snugger sides than the default loose profile. */
|
||||
.portal-billing__stack .sui-card--pad-loose {
|
||||
padding: 0.95rem 1.5rem;
|
||||
}
|
||||
|
||||
.portal-billing__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.portal-billing__row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-billing__row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.portal-billing__eyebrow {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.portal-billing__section-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.portal-billing__section-sub {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-3);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.portal-billing__skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-billing__error {
|
||||
color: var(--color-red, #b91c1c);
|
||||
font-size: 0.875rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* ── Meter card ─────────────────────────────────────────────────────────── */
|
||||
.portal-billing__meter-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.portal-billing__meter-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-billing__meter-figures {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.portal-billing__meter-figure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.portal-billing__meter-num {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-billing__meter-num--muted {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-billing__meter-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-billing__meter-track {
|
||||
height: 0.5rem;
|
||||
background: var(--color-bg-subtle, #e5e7eb);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-billing__meter-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-blue, #0a8bff),
|
||||
var(--color-purple, #8b5cf6)
|
||||
);
|
||||
border-radius: 999px;
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
|
||||
.portal-billing__meter-foot {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
|
||||
/* ── SaaS-shared meter primitives ───────────────────────────────────────────
|
||||
Same class names + structure as
|
||||
editor/src/cloud/components/shared/config/configSections/{Payg,PaygFree}.css
|
||||
so the visual treatment matches the SaaS plan page. When the shared move
|
||||
lands these can be deduped via a shared stylesheet. */
|
||||
/* Flat by default — the meter sits directly on its card, no inner panel box
|
||||
(shared by the free trial meter and the subscribed spend meter). */
|
||||
.paygf-meter {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.paygf-meter__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.paygf-meter__figure {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
}
|
||||
.paygf-meter__num {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text-1, #0f172a);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.paygf-meter__cap {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-3, #64748b);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.paygf-meter .payg-bar {
|
||||
margin-top: 11px;
|
||||
}
|
||||
.paygf-meter__meta {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-2, #475569);
|
||||
}
|
||||
|
||||
/* Status chip (Healthy / Approaching / Cap reached). Solid hex colours
|
||||
intentionally — the chip palette is semantic, not theme-bound. */
|
||||
.payg-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 13px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.payg-status__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.payg-status[data-state="FULL"] {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.payg-status[data-state="FULL"] .payg-status__dot {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
.payg-status[data-state="WARNED"] {
|
||||
background: #fef3c7;
|
||||
color: #a16207;
|
||||
}
|
||||
.payg-status[data-state="WARNED"] .payg-status__dot {
|
||||
background: #eab308;
|
||||
box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2);
|
||||
}
|
||||
.payg-status[data-state="DEGRADED"] {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.payg-status[data-state="DEGRADED"] .payg-status__dot {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Segmented usage bar */
|
||||
.payg-bar {
|
||||
margin-top: 18px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-subtle, #e5e7eb);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.payg-bar__fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.payg-bar__fill[data-state="FULL"] {
|
||||
background: linear-gradient(90deg, #0a8bff, #38bdf8);
|
||||
}
|
||||
.payg-bar__fill[data-state="WARNED"] {
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
}
|
||||
.payg-bar__fill[data-state="DEGRADED"] {
|
||||
background: linear-gradient(90deg, #dc2626, #f87171);
|
||||
}
|
||||
|
||||
/* ── Plan card (free state) ─────────────────────────────────────────────── */
|
||||
.portal-billing__plan-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-billing__plan-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-billing__plan-sub {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-2);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.portal-billing__plan-features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-billing__plan-features li {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-2);
|
||||
padding-left: 1.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.portal-billing__plan-features li::before {
|
||||
content: "✓";
|
||||
color: var(--color-green, #10b981);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-billing__plan-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-billing__plan-reassure {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-billing__plan-readonly {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-3);
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Category breakdown ─────────────────────────────────────────────────── */
|
||||
.portal-billing__breakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-billing__breakdown-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.portal-billing__breakdown-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-billing__breakdown-value {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-billing__breakdown-track {
|
||||
height: 0.5rem;
|
||||
background: var(--color-bg-subtle, #e5e7eb);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portal-billing__breakdown-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
|
||||
.portal-billing__breakdown-fill--blue {
|
||||
background: #0a8bff;
|
||||
}
|
||||
|
||||
.portal-billing__breakdown-fill--purple {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
.portal-billing__breakdown-fill--teal {
|
||||
background: #06b6d4;
|
||||
}
|
||||
|
||||
/* ── Cap control ────────────────────────────────────────────────────────── */
|
||||
.portal-billing__cap-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-billing__cap-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-billing__cap-input {
|
||||
width: 8rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.9375rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.portal-billing__cap-nocap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.portal-billing__cap-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Members + invoices ─────────────────────────────────────────────────── */
|
||||
.portal-billing__member-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.portal-billing__member-name {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-billing__member-email {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-billing__invoice-num {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.portal-billing__invoice-desc {
|
||||
color: var(--color-text-1, #0f172a);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
/* Tables that sit flush inside an already-surfaced Card: drop the table's
|
||||
own border + background + radius so only the row dividers (kept by sui-table
|
||||
defaults) show. Column headers are hidden too — small self-evident tables
|
||||
read better without them. Used by the Invoice history + Per-member usage
|
||||
tables. */
|
||||
.portal-billing__flush-table {
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
.portal-billing__flush-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.portal-billing__subscription-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-billing__subscription-head > div:first-child {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-billing__subscription-head
|
||||
> div:first-child
|
||||
.portal-billing__section-sub {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Spend-cap control — ported from the SaaS cloud SpendCapControl.css.
|
||||
Same class names + structure so the visual is identical to the SaaS Plan
|
||||
page; when the shared-component move lands, these dedupe to one stylesheet. */
|
||||
.scc {
|
||||
--scc-accent: #0a8bff;
|
||||
--scc-accent-text: #0a8bff;
|
||||
--scc-accent-soft: rgba(10, 139, 255, 0.12);
|
||||
--scc-accent-border: rgba(10, 139, 255, 0.25);
|
||||
--scc-chip-bg: var(--color-bg-muted, #f8fafc);
|
||||
--scc-chip-border: var(--color-border, #e2e8f0);
|
||||
--scc-text-primary: var(--color-text-1, #0f172a);
|
||||
--scc-text-secondary: var(--color-text-2, #475569);
|
||||
--scc-text-muted: var(--color-text-3, #64748b);
|
||||
--scc-border-strong: var(--color-text-4, #94a3b8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .scc {
|
||||
--scc-accent-text: #66b8ff;
|
||||
--scc-accent-soft: rgba(10, 139, 255, 0.16);
|
||||
--scc-chip-bg: #272d35;
|
||||
--scc-chip-border: #3d444e;
|
||||
}
|
||||
|
||||
.scc-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scc-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--scc-chip-border);
|
||||
background: var(--scc-chip-bg);
|
||||
color: var(--scc-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition:
|
||||
color 0.12s ease,
|
||||
border-color 0.12s ease,
|
||||
background 0.12s ease;
|
||||
}
|
||||
.scc-chip:hover:not(:disabled) {
|
||||
color: var(--scc-text-primary);
|
||||
border-color: var(--scc-border-strong);
|
||||
}
|
||||
.scc-chip[data-selected="true"] {
|
||||
background: var(--scc-accent-soft);
|
||||
border-color: var(--scc-accent);
|
||||
color: var(--scc-accent-text);
|
||||
}
|
||||
.scc-chip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.scc-custom {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed var(--scc-chip-border);
|
||||
background: transparent;
|
||||
cursor: text;
|
||||
transition:
|
||||
border-color 0.12s ease,
|
||||
background 0.12s ease;
|
||||
}
|
||||
.scc-custom:hover {
|
||||
border-color: var(--scc-border-strong);
|
||||
}
|
||||
.scc-custom[data-active="true"] {
|
||||
border-style: solid;
|
||||
border-color: var(--scc-accent);
|
||||
background: var(--scc-accent-soft);
|
||||
}
|
||||
.scc-custom__symbol {
|
||||
color: var(--scc-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.scc-custom[data-active="true"] .scc-custom__symbol,
|
||||
.scc-custom[data-active="true"] .scc-custom__input {
|
||||
color: var(--scc-accent-text);
|
||||
}
|
||||
.scc-custom__input {
|
||||
width: 70px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--scc-text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.scc-custom__input::placeholder {
|
||||
color: var(--scc-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scc-row__spacer {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.scc-estimate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--scc-accent-soft);
|
||||
border: 1px solid var(--scc-accent-border);
|
||||
}
|
||||
.scc-estimate__icon {
|
||||
color: var(--scc-accent-text);
|
||||
display: flex;
|
||||
}
|
||||
.scc-estimate__main {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 550;
|
||||
color: var(--scc-text-primary);
|
||||
}
|
||||
.scc-estimate__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--scc-text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.scc-note {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--scc-text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ── Plan header (free-vs-metered split) — ported from SaaS payg-planhead ── */
|
||||
.portal-billing__planhead-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.portal-billing__planhead-eyebrow {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.portal-billing__role-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.portal-billing__role-pill[data-leader="true"] {
|
||||
background: rgba(10, 139, 255, 0.12);
|
||||
color: #0a8bff;
|
||||
border: 1px solid rgba(10, 139, 255, 0.25);
|
||||
}
|
||||
.portal-billing__role-pill[data-leader="false"] {
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-3);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.portal-billing__planhead-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
}
|
||||
.portal-billing__planhead-col {
|
||||
padding-right: 22px;
|
||||
}
|
||||
.portal-billing__planhead-col--meter {
|
||||
padding-right: 0;
|
||||
padding-left: 22px;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
.portal-billing__planhead-lbl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.portal-billing__planhead-lbl--free {
|
||||
color: #10b981;
|
||||
}
|
||||
.portal-billing__planhead-lbl--meter {
|
||||
color: #0a8bff;
|
||||
}
|
||||
.portal-billing__planhead-title {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-1);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.portal-billing__planhead-body {
|
||||
margin: 5px 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* The period meter merged into the plan-head card, divided from the split. */
|
||||
.portal-billing__planhead-meter {
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.portal-billing__planhead-split {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.portal-billing__planhead-col {
|
||||
padding-right: 0;
|
||||
}
|
||||
.portal-billing__planhead-col--meter {
|
||||
padding-left: 0;
|
||||
padding-top: 16px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.portal-billing__invoice-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.portal-billing__invoice-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1, #0f172a);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.portal-billing__invoice-link:hover {
|
||||
background: var(--color-bg-muted, #f1f5f9);
|
||||
border-color: var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.portal-billing__invoice-link:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-blue, #0a8bff);
|
||||
background: var(--color-bg-muted, #f1f5f9);
|
||||
}
|
||||
|
||||
.portal-billing__invoice-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Current plan header ──────────────────────────────────────────────── */
|
||||
.portal-billing__current-plan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.portal-billing__current-plan-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.portal-billing__current-plan-name {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
/* ── Free PDF Editors (fleet) card ───────────────────────────────────── */
|
||||
.portal-billing__editors-id {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.portal-billing__editors-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
line-height: 0;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
/* Identity, stats, and the "Invite teammates" action all sit on one line; the
|
||||
metric cells render flat (no per-stat box) and are divided by hairlines, so
|
||||
the card reads as a single strip rather than a box-in-a-box. */
|
||||
.portal-billing__fleet-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
.portal-billing__fleet-row .portal-billing__editors-id {
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.portal-billing__fleet-metrics {
|
||||
flex: 1;
|
||||
}
|
||||
.portal-billing__fleet-metrics .sui-metric {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
padding: 0 0 0 1.5rem;
|
||||
min-width: 0;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
/* Uppercase stat labels to match the marketing treatment. */
|
||||
.portal-billing__fleet-metrics .sui-metric__label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@media (max-width: 60rem) {
|
||||
.portal-billing__fleet-row {
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.portal-billing__fleet-metrics {
|
||||
flex: 1 1 100%;
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
|
||||
/* "N left" reads as quiet gray text, like the "x of y" suffix — not a status pill. */
|
||||
.portal-billing__trial-meter .payg-status,
|
||||
.portal-billing__spend-meter .payg-status {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3, #64748b);
|
||||
}
|
||||
.portal-billing__trial-meter .payg-status__dot,
|
||||
.portal-billing__spend-meter .payg-status__dot {
|
||||
display: none;
|
||||
}
|
||||
/* Spend meter: anchor the "remaining" half of the meta line to the right edge. */
|
||||
.portal-billing__spend-meter .paygf-meter__meta {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ── Enterprise / volume-discount card ───────────────────────────────── */
|
||||
.portal-billing__enterprise-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
/* Bare variant — embeds in another card's column without its own surface. */
|
||||
.portal-billing__enterprise-bare {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── PDFs processed + category split (subscribed) ────────────────────── */
|
||||
.portal-billing__bignum-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin: 0.35rem 0 0.85rem;
|
||||
}
|
||||
.portal-billing__bignum {
|
||||
font-size: 2rem;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.portal-billing__bignum-unit {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.portal-billing__segbar {
|
||||
display: flex;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-muted, #f1f5f9);
|
||||
}
|
||||
.portal-billing__segbar-seg {
|
||||
height: 100%;
|
||||
}
|
||||
.portal-billing__segbar-seg--blue {
|
||||
background: #0a8bff;
|
||||
}
|
||||
.portal-billing__segbar-seg--purple {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
.portal-billing__segbar-seg--teal {
|
||||
background: #06b6d4;
|
||||
}
|
||||
.portal-billing__seglegend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem 1.75rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
.portal-billing__seglegend-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.portal-billing__dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.portal-billing__dot--blue {
|
||||
background: #0a8bff;
|
||||
}
|
||||
.portal-billing__dot--purple {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
.portal-billing__dot--teal {
|
||||
background: #06b6d4;
|
||||
}
|
||||
.portal-billing__seglegend-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.portal-billing__seglegend-val {
|
||||
color: var(--color-text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.portal-billing__seglegend-desc {
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ── Spend-limit projection line ─────────────────────────────────────── */
|
||||
.portal-billing__projection {
|
||||
margin: 0.85rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.portal-billing__projection strong {
|
||||
color: var(--color-amber, #d97706);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
|
||||
/** Linked, on the one-time free grant — leader view. Override per story. */
|
||||
export const freeWallet: Wallet = {
|
||||
teamId: 42,
|
||||
status: "free",
|
||||
role: "leader",
|
||||
billingPeriodStart: "2026-06-01",
|
||||
billingPeriodEnd: "2026-06-30",
|
||||
billableUsed: 120,
|
||||
billableLimit: 500,
|
||||
freeAllowance: 500,
|
||||
freeRemaining: 380,
|
||||
pricePerDocMinor: 2,
|
||||
currency: "usd",
|
||||
estimatedBillMinor: null,
|
||||
capUsd: null,
|
||||
noCap: false,
|
||||
stripeSubscriptionId: null,
|
||||
spendUnitsThisPeriod: 120,
|
||||
categoryBreakdown: { api: 40, ai: 30, automation: 50 },
|
||||
members: [],
|
||||
recent: [],
|
||||
};
|
||||
|
||||
/** Linked + subscribed (Processor plan), capped, leader view with members. */
|
||||
export const subscribedWallet: Wallet = {
|
||||
teamId: 42,
|
||||
status: "subscribed",
|
||||
role: "leader",
|
||||
billingPeriodStart: "2026-06-01",
|
||||
billingPeriodEnd: "2026-06-30",
|
||||
billableUsed: 2250,
|
||||
billableLimit: 50000,
|
||||
freeAllowance: 500,
|
||||
freeRemaining: 0,
|
||||
pricePerDocMinor: 2,
|
||||
currency: "usd",
|
||||
estimatedBillMinor: 4500,
|
||||
capUsd: 1000,
|
||||
noCap: false,
|
||||
stripeSubscriptionId: "sub_123",
|
||||
spendUnitsThisPeriod: 2250,
|
||||
categoryBreakdown: { api: 900, ai: 600, automation: 750 },
|
||||
members: [
|
||||
{
|
||||
userId: "u1",
|
||||
name: "Ada Lovelace",
|
||||
email: "ada@acme.test",
|
||||
spendUnits: 1400,
|
||||
},
|
||||
{
|
||||
userId: "u2",
|
||||
name: "Alan Turing",
|
||||
email: "alan@acme.test",
|
||||
spendUnits: 850,
|
||||
},
|
||||
],
|
||||
recent: [],
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user