Portal: realign home hero to the simplified marketing card (#6956)

## What

Reworks the free-tier home hero (`WelcomeBanner` + `SetupChecklist`) to
match marketing's reworked top card: a compact product header over
numbered getting-started steps, dropping the marketing chrome.

## aim
attachments/assets/75a80e5f-119e-46bb-80e7-fc4b9a62e5b6" />
<img width="1098" height="646" alt="01-aim-marketing-demo"
src="https://github.com/user-attachments/assets/681ca1b8-219e-4afe-9748-89435aafd440"
/>

## old hero
<img width="1800" height="1338" alt="02-before-old-hero"
src="https://github.com/user-attachments/assets/a396cec0-4752-4ac0-9951-9a49f9d50ea7"
/>


## new screenshots

<img width="1800" height="626" alt="03-after-onboarding-card"
src="https://github.com/user-attachments/assets/59332111-6c49-438d-af6d-200d99bf0f8f"
/>
<img width="1800" height="180" alt="04-after-deployed-header"
src="https://github.com/user-attachments/assets/ea631e6c-7878-4159-aaf7-1f0c2bd795ce"
/>
<img width="1024" height="1396" alt="05-after-install-modal-list"
src="https://github.com/user-attachments/assets/125d0a11-d799-40f0-bd8b-42f5dedcfe6d"
/>
<img width="1024" height="858" alt="06-after-install-modal-docker"
src="https://github.com/user-

## Changes

- **Compact dark header:** brand mark + "PDF Editor" + social-proof
stats (`30M downloads · 60+ PDF operations · Free forever`) + a single
**Open in browser** CTA (→ `EDITOR_URL`).
- **Dropped** the decorative editor mock, marketing
title/subtitle/"Open-source" badge/perks, the two extra banner buttons,
and the checklist's dismiss/progress/done tracking.
- **Numbered nav steps** (①②③) — each opens its in-app surface:

| # | Step | Goes to | Change |
|---|------|---------|--------|
| ① | Download the editor | `editor` view | was an external
`stirling.com/download` link → now in-app |
| ② | Confirm your policies | `policies` view | live active/recommended
counts retained |
| ③ | Invite teammates | `users` view | **replaces** "Connect your
sources" (sources dropped to match the demo) |

- **Enterprise rung** unchanged (Start Trial / Get Quote → procurement).

## Notes

- **Shared hero** — self-hosted sees it too (per decision).
- **One deliberate deviation from the demo:** the header CTA is blue
(brand primary) rather than the demo's white button. Trivial to flip —
say the word.
- Behaviour change: the hero is now a quick-start (navigational) rather
than a completion checklist — the dismiss control + per-step done chips
are gone to match the demo.
- Supersedes the incremental #6944 ("add Open in browser" 3-button
version) — that can be closed in favour of this.
- Portal `tsc` clean; `unusedTranslations` green (removed orphaned
welcome/onboarding keys, added the new ones).
This commit is contained in:
ConnorYoh
2026-07-10 13:20:18 +00:00
committed by GitHub
parent b36f3e0875
commit b9a7f2083b
17 changed files with 970 additions and 926 deletions
@@ -6250,7 +6250,7 @@ bodyBefore = "Set"
title = "Stripe not configured"
[portal.billing.enterpriseUpsell]
cta = "Build your Enterprise quote"
cta = "Explore Enterprise"
description = "Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships."
eyebrow = "Volume discount · 1M+ PDFs"
title = "Stirling Enterprise"
@@ -6738,8 +6738,63 @@ description = "This view hit an unexpected error. Try again, or pick another sec
retry = "Try again"
title = "Something went wrong on this page"
[portal.home.download]
back = "Back"
body = "Download the desktop app or self-host the server."
done = "Done"
guide = "Open the {{name}} guide"
openInBrowser = "Open in browser instead"
sectionDesktop = "Desktop app"
sectionSelfHosted = "Self-hosted"
title = "Install the Stirling PDF Editor"
[portal.home.download.docker]
detailBody = "Run the official image on your server."
note = "Opens at :8080. Default login: admin / stirling."
tagline = "Run the server in a container"
title = "Docker"
variantFat = "Fat"
variantLite = "Ultra-Lite"
variantStandard = "Standard"
[portal.home.download.kubernetes]
detailBody = "Install the official Helm chart."
note = "Add a PVC for /configs to persist settings."
tagline = "Deploy to a cluster with Helm"
title = "Kubernetes"
[portal.home.download.linux]
detailBody = "Native builds for your distribution."
guideBtn = "View Linux install options"
note = "AppImage, .deb and .rpm builds are available for common distros."
tagline = "Native desktop app for your distro"
title = "Linux"
[portal.home.download.mac]
altLabel = "Or via Homebrew"
detailBody = "Run the installer, or use Homebrew."
downloadBtn = "Download for macOS"
tagline = "Native app for Apple silicon and Intel"
title = "macOS"
[portal.home.download.manual]
detailBody = "Run the .jar on a bare metal server."
downloadBtn = "Download the .jar"
note = "Requires Java 21+. Opens at :8080; default login admin / stirling."
runLabel = "Then run it"
tagline = "Run the .jar on a bare metal server"
title = "Manual server setup"
[portal.home.download.windows]
altLabel = "Or via winget"
detailBody = "Run the installer, or use a package manager."
downloadBtn = "Download installer"
tagline = "Native app for Microsoft Windows"
title = "Windows"
[portal.home.editor]
activeUsers = "{{n}} active"
install = "Install the editor"
invite = "Invite teammates"
name = "Stirling PDF Editor"
open = "Open in browser"
@@ -6755,12 +6810,6 @@ afternoon = "Good afternoon"
evening = "Good evening"
morning = "Good morning"
[portal.home.onboarding]
dismiss = "Dismiss setup"
notStarted = "Not started"
progress = "{{done}} of {{total}} done"
title = "Finish setting up"
[portal.home.onboarding.enterprise]
body = "Org-wide SSO + SCIM + RBAC, committed volume pricing, and air-gapped deployment."
cta = "Start Trial"
@@ -6769,18 +6818,16 @@ lead = "For 250+ employees."
tag = "Enterprise"
[portal.home.onboarding.steps.editor]
blurb = "Deploy the desktop app, free forever."
title = "Download the PDF Editor"
blurb = "Install the desktop app or self-host"
title = "Download the editor"
[portal.home.onboarding.steps.invite]
blurb = "Bring your team into the secure workspace"
title = "Invite teammates"
[portal.home.onboarding.steps.policies]
blurb = "{{active}} active · {{recommended}} recommended"
chip = "{{active}} active"
title = "Turn on policies"
[portal.home.onboarding.steps.sources]
blurb = "{{connected}} connected · every PDF governed where it lands"
chip = "{{connected}} connected"
title = "Connect your sources"
title = "Confirm your policies"
[portal.home.quickActions]
subtitle = "Top tasks for today"
@@ -8076,14 +8123,11 @@ unlimited = "{{used}} · Unlimited"
[portal.welcome]
ariaLabel = "Welcome to Stirling PDF"
badge = "Open-source"
installEditor = "Install the Editor"
inviteTeammates = "Invite teammates"
install = "Install the editor"
invite = "Invite teammates"
openInBrowser = "Open in browser"
perks = "Free forever · Self-hostable"
subtitle = "The world's most secure PDF Editor is free for teams of all sizes. Includes 60+ PDF operations and SSO."
title = "Welcome to"
titleAccent = "Stirling PDF"
productName = "PDF Editor"
stats = "30M downloads · 60+ PDF operations · Free forever"
[printFile]
title = "Print File"
@@ -0,0 +1,114 @@
/* ──────────────────────────────────────────────────────────────────────── */
/* Install-the-editor modal */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-install__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-install__section {
margin: 0.75rem 0 0.125rem;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-5);
}
.portal-install__section:first-child {
margin-top: 0;
}
.portal-install__option {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.875rem;
width: 100%;
padding: 0.75rem 0.875rem;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
background: var(--color-surface);
text-align: left;
cursor: pointer;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-install__option:hover {
border-color: var(--color-border-input);
background: var(--color-bg-hover);
}
.portal-install__option-icon {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
flex-shrink: 0;
border-radius: 0.625rem;
color: var(--color-blue);
background: var(--color-blue-light);
}
.portal-install__option-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.portal-install__option-text strong {
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-install__option-text span {
font-size: 0.8125rem;
color: var(--color-text-4);
}
.portal-install__option-chevron {
color: var(--color-text-5);
flex-shrink: 0;
}
/* ── Detail pane ── */
.portal-install__detail {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.875rem;
}
.portal-install__detail .sui-btn {
width: 100%;
}
.portal-install__eyebrow {
margin: 0;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-5);
}
.portal-install__note {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--color-text-4);
}
.portal-install__guide {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0;
border: none;
background: none;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-blue);
cursor: pointer;
}
.portal-install__guide:hover {
text-decoration: underline;
}
@@ -0,0 +1,282 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, CodeBlock, Modal, SegmentedControl } from "@app/ui";
import { EDITOR_URL } from "@portal/auth/editorUrl";
import { markEditorInstalled } from "@portal/hooks/useEditorInstalled";
import { DOWNLOAD_URLS } from "@app/constants/downloads";
import DownloadRounded from "@mui/icons-material/DownloadRounded";
import DnsRounded from "@mui/icons-material/DnsRounded";
import LayersRounded from "@mui/icons-material/LayersRounded";
import TerminalRounded from "@mui/icons-material/TerminalRounded";
import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded";
import OpenInNewRounded from "@mui/icons-material/OpenInNewRounded";
import type { SvgIconComponent } from "@mui/icons-material";
import "@portal/components/DownloadEditorModal.css";
/* ──────────────────────────────────────────────────────────────────────── */
/* Install commands + guides (code isn't translated; only labels are) */
/* ──────────────────────────────────────────────────────────────────────── */
const WINGET = "winget install StirlingTools.StirlingPDF";
const BREW = "brew install --cask stirling-pdf";
const dockerCmd = (tag: string) =>
`docker run -d --name stirling-pdf -p 8080:8080 \\\n -v ./stirling-data:/configs \\\n stirlingtools/stirling-pdf:${tag}`;
const HELM = `helm repo add stirling-pdf https://stirling-tools.github.io/Stirling-PDF/
helm repo update
helm install stirling-pdf stirling-pdf/stirling-pdf-chart \\\n --namespace stirling-pdf --create-namespace`;
const JAR_URL = "https://files.stirlingpdf.com/Stirling-PDF-with-login.jar";
const JAR =
"java -Xmx2g -jar Stirling-PDF-with-login.jar\n# then open http://localhost:8080";
const GUIDES = {
windows: "https://docs.stirlingpdf.com/Installation/Windows%20Installation/",
mac: "https://docs.stirlingpdf.com/Installation/Mac%20Installation/",
linux: DOWNLOAD_URLS.LINUX_DOCS,
docker: "https://docs.stirlingpdf.com/Installation/Docker%20Install",
kubernetes: "https://docs.stirlingpdf.com/Installation/Kubernetes",
manual: DOWNLOAD_URLS.LINUX_DOCS,
} as const;
type OptionId = keyof typeof GUIDES;
type DockerVariant = "latest" | "latest-fat" | "latest-ultra-lite";
const DESKTOP: OptionId[] = ["windows", "mac", "linux"];
const SELF_HOSTED: OptionId[] = ["docker", "kubernetes", "manual"];
const ICONS: Record<OptionId, SvgIconComponent> = {
windows: DownloadRounded,
mac: DownloadRounded,
linux: DownloadRounded,
docker: DnsRounded,
kubernetes: LayersRounded,
manual: TerminalRounded,
};
interface Props {
open: boolean;
onClose: () => void;
}
function openUrl(url: string) {
window.open(url, "_blank", "noopener,noreferrer");
}
/**
* Install-the-editor modal. Lists desktop + self-hosted options; each opens a
* detail pane with a download button and/or copyable install command and a
* guide link. A completing action — clicking a download button or pressing Done
* — marks the getting-started "Download the editor" step complete (via
* {@link markEditorInstalled}); nothing else is persisted.
*/
export function DownloadEditorModal({ open, onClose }: Props) {
const { t } = useTranslation();
const [selected, setSelected] = useState<OptionId | null>(null);
const [dockerVariant, setDockerVariant] = useState<DockerVariant>("latest");
const close = () => {
setSelected(null);
onClose();
};
// A download or a Done press completes the "Download the editor" step.
const download = (url: string) => {
openUrl(url);
markEditorInstalled();
};
const done = () => {
markEditorInstalled();
close();
};
const guideLink = (id: keyof typeof GUIDES) => (
<button
type="button"
className="portal-install__guide"
onClick={() => openUrl(GUIDES[id])}
>
{t("portal.home.download.guide", {
name: t(`portal.home.download.${id}.title`),
})}
<OpenInNewRounded sx={{ fontSize: 15 }} />
</button>
);
const note = (id: OptionId) => (
<p className="portal-install__note">
{t(`portal.home.download.${id}.note`)}
</p>
);
function renderDetail(id: OptionId) {
switch (id) {
case "windows":
case "mac": {
const url =
id === "windows" ? DOWNLOAD_URLS.WINDOWS : DOWNLOAD_URLS.MAC;
return (
<>
<Button
variant="primary"
leftSection={<DownloadRounded sx={{ fontSize: 16 }} />}
onClick={() => download(url)}
>
{t(`portal.home.download.${id}.downloadBtn`)}
</Button>
<p className="portal-install__eyebrow">
{t(`portal.home.download.${id}.altLabel`)}
</p>
<CodeBlock code={id === "windows" ? WINGET : BREW} lang="bash" />
{guideLink(id)}
</>
);
}
case "linux":
return (
<>
{note("linux")}
<Button
variant="secondary"
leftSection={<OpenInNewRounded sx={{ fontSize: 16 }} />}
onClick={() => openUrl(GUIDES.linux)}
>
{t("portal.home.download.linux.guideBtn")}
</Button>
</>
);
case "docker":
return (
<>
<SegmentedControl<DockerVariant>
value={dockerVariant}
onChange={setDockerVariant}
options={[
{
label: t("portal.home.download.docker.variantStandard"),
value: "latest",
},
{
label: t("portal.home.download.docker.variantFat"),
value: "latest-fat",
},
{
label: t("portal.home.download.docker.variantLite"),
value: "latest-ultra-lite",
},
]}
/>
<CodeBlock code={dockerCmd(dockerVariant)} lang="bash" />
{note("docker")}
{guideLink("docker")}
</>
);
case "kubernetes":
return (
<>
<CodeBlock code={HELM} lang="bash" />
{note("kubernetes")}
{guideLink("kubernetes")}
</>
);
case "manual":
return (
<>
<Button
variant="primary"
leftSection={<DownloadRounded sx={{ fontSize: 16 }} />}
onClick={() => download(JAR_URL)}
>
{t("portal.home.download.manual.downloadBtn")}
</Button>
<p className="portal-install__eyebrow">
{t("portal.home.download.manual.runLabel")}
</p>
<CodeBlock code={JAR} lang="bash" />
{note("manual")}
{guideLink("manual")}
</>
);
}
}
const renderRow = (id: OptionId) => {
const Icon = ICONS[id];
return (
<button
key={id}
type="button"
className="portal-install__option"
onClick={() => setSelected(id)}
>
<span className="portal-install__option-icon" aria-hidden>
<Icon sx={{ fontSize: 20 }} />
</span>
<span className="portal-install__option-text">
<strong>{t(`portal.home.download.${id}.title`)}</strong>
<span>{t(`portal.home.download.${id}.tagline`)}</span>
</span>
<ChevronRightRounded
className="portal-install__option-chevron"
sx={{ fontSize: 20 }}
aria-hidden
/>
</button>
);
};
const detail = selected !== null;
return (
<Modal
open={open}
onClose={close}
width="md"
title={
detail
? t(`portal.home.download.${selected}.title`)
: t("portal.home.download.title")
}
subtitle={
detail
? t(`portal.home.download.${selected}.detailBody`)
: t("portal.home.download.body")
}
footer={
detail ? (
<div style={{ display: "flex", gap: "0.5rem" }}>
<Button variant="secondary" onClick={() => setSelected(null)}>
{t("portal.home.download.back")}
</Button>
<Button variant="primary" onClick={done}>
{t("portal.home.download.done")}
</Button>
</div>
) : (
<Button
variant="secondary"
leftSection={<OpenInNewRounded sx={{ fontSize: 15 }} />}
onClick={() => {
window.location.href = EDITOR_URL;
}}
>
{t("portal.home.download.openInBrowser")}
</Button>
)
}
>
{detail ? (
<div className="portal-install__detail">{renderDetail(selected)}</div>
) : (
<div className="portal-install__list">
<p className="portal-install__section">
{t("portal.home.download.sectionDesktop")}
</p>
{DESKTOP.map(renderRow)}
<p className="portal-install__section">
{t("portal.home.download.sectionSelfHosted")}
</p>
{SELF_HOSTED.map(renderRow)}
</div>
)}
</Modal>
);
}
@@ -14,7 +14,8 @@
display: flex;
align-items: center;
gap: 1.25rem;
padding: 1.25rem 1.5rem;
padding: 1rem 1.25rem;
background: #16213e;
}
.portal-editor-hero__logo {
@@ -51,7 +52,7 @@
.portal-editor-hero__name {
font-size: 1.125rem;
font-weight: 700;
color: var(--color-text-1);
color: #fff;
margin-right: 0.125rem;
}
@@ -59,27 +60,21 @@
display: inline-flex;
align-items: center;
gap: 0.3125rem;
padding: 0.3125rem 0.75rem;
padding: 0.25rem 0.6875rem;
border-radius: var(--radius-pill);
border: 1px solid var(--color-border-input);
background: var(--color-bg-code);
color: var(--color-text-2);
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
font-size: 0.71875rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
transition:
background var(--motion-fast),
box-shadow var(--motion-fast),
transform var(--motion-fast);
transition: background var(--motion-fast);
}
.portal-editor-hero__chip:hover {
background: var(--color-surface);
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.1);
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.16);
}
.portal-editor-hero__chip svg {
color: var(--color-text-4);
color: rgba(255, 255, 255, 0.6);
}
.portal-editor-hero__meta {
@@ -88,19 +83,51 @@
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.75rem;
color: var(--color-text-5);
color: rgba(255, 255, 255, 0.5);
}
.portal-editor-hero__host {
color: var(--color-text-2);
color: rgba(255, 255, 255, 0.92);
font-family: var(--font-mono);
font-weight: 500;
}
.portal-editor-hero__meta-sep {
color: var(--color-border-input);
color: rgba(255, 255, 255, 0.3);
}
.portal-editor-hero__action {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Icon-only actions (e.g. install) on the dark header. */
.portal-editor-hero__icon-btn {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
flex-shrink: 0;
border-radius: var(--radius-md);
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.08);
color: #fff;
cursor: pointer;
transition: background var(--motion-fast);
}
.portal-editor-hero__icon-btn:hover {
background: rgba(255, 255, 255, 0.16);
}
/* White CTA on the dark header, matching the marketing card. */
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn {
background: #ffffff;
border-color: #ffffff;
color: #16213e;
}
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover {
background: rgba(255, 255, 255, 0.88);
border-color: rgba(255, 255, 255, 0.88);
}
@media (max-width: 48rem) {
@@ -2,6 +2,18 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse } from "msw";
import { EditorStatusCard } from "@portal/components/EditorStatusCard";
import { SetupChecklist } from "@portal/components/SetupChecklist";
import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress";
const progress: OnboardingProgress = {
loading: false,
deployed: true,
editorDone: true,
policiesDone: true,
inviteDone: false,
policiesActive: 3,
policiesRecommended: 4,
allComplete: false,
};
const meta: Meta<typeof EditorStatusCard> = {
title: "Portal/Home/EditorStatusCard",
@@ -25,7 +37,7 @@ export const Default: Story = {};
/** As it renders on the subscribed home: the setup checklist attached as the footer. */
export const WithSetupChecklist: Story = {
args: {
footer: <SetupChecklist />,
footer: <SetupChecklist progress={progress} />,
},
};
@@ -36,7 +48,7 @@ export const WithSetupChecklist: Story = {
*/
export const DeploymentUnavailable: Story = {
args: {
footer: <SetupChecklist />,
footer: <SetupChecklist progress={progress} />,
},
parameters: {
msw: {
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Fragment, useMemo } from "react";
import { Fragment, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Skeleton } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
@@ -11,10 +11,12 @@ import {
type EditorInstance,
} from "@portal/api/editorDeploy";
import {
DownloadIcon,
ExternalLinkIcon,
UsersIcon,
UserPlusIcon,
} from "@portal/components/icons";
import { DownloadEditorModal } from "@portal/components/DownloadEditorModal";
import "@portal/components/EditorStatusCard.css";
/** The Stirling brand mark, drawn at the hero size. Decorative. */
@@ -73,6 +75,7 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) {
const { t } = useTranslation();
const { tier } = useTier();
const { setActiveView } = useView();
const [installOpen, setInstallOpen] = useState(false);
const { data, loading } = useAsync<EditorDeploymentResponse>(
() => fetchEditorDeployment(tier),
[tier],
@@ -138,26 +141,16 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) {
{t("portal.home.editor.name")}
</span>
{!hideChips && (
<>
<button
type="button"
className="portal-editor-hero__chip"
onClick={() => setActiveView("users")}
>
<UsersIcon size={13} />
{t("portal.home.editor.activeUsers", {
n: view.activeUsers,
})}
</button>
<button
type="button"
className="portal-editor-hero__chip"
onClick={() => setActiveView("users")}
>
<UserPlusIcon size={13} />
{t("portal.home.editor.invite")}
</button>
</>
<button
type="button"
className="portal-editor-hero__chip"
onClick={() => setActiveView("users")}
>
<UsersIcon size={13} />
{t("portal.home.editor.activeUsers", {
n: view.activeUsers,
})}
</button>
)}
</div>
<div className="portal-editor-hero__meta">
@@ -174,8 +167,29 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) {
</div>
<div className="portal-editor-hero__action">
{!hideChips && (
<button
type="button"
className="portal-editor-hero__icon-btn"
onClick={() => setActiveView("users")}
aria-label={t("portal.home.editor.invite")}
title={t("portal.home.editor.invite")}
>
<UserPlusIcon size={16} />
</button>
)}
<button
type="button"
className="portal-editor-hero__icon-btn"
onClick={() => setInstallOpen(true)}
aria-label={t("portal.home.editor.install")}
title={t("portal.home.editor.install")}
>
<DownloadIcon size={16} />
</button>
<Button
variant="primary"
className="portal-editor-hero__cta"
leftSection={<ExternalLinkIcon size={13} />}
disabled={!ready || !view}
onClick={() => {
@@ -189,6 +203,11 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) {
</div>
{footer && <div className="portal-editor-hero__footer">{footer}</div>}
<DownloadEditorModal
open={installOpen}
onClose={() => setInstallOpen(false)}
/>
</section>
);
}
@@ -3,25 +3,28 @@ import { useUI } from "@portal/contexts/UIContext";
import { WelcomeBanner } from "@portal/components/WelcomeBanner";
import { EditorStatusCard } from "@portal/components/EditorStatusCard";
import { SetupChecklist } from "@portal/components/SetupChecklist";
import { useOnboardingProgress } from "@portal/hooks/useOnboardingProgress";
import { ControlledDealStatusHero } from "@portal/components/procurement/ProcurementBanner";
import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow";
import { useProcurement } from "@portal/components/procurement/useProcurement";
/**
* The Home hero, composed per tier with a procurement-aware footer:
* The Home hero, composed with a procurement-aware, progress-aware footer:
*
* - free → marketing welcome banner
* - subscribed → deployed-Editor status card
* - enterprise → deployed-Editor status card (chips hidden; the deal hero owns invite)
* - no live deployment → welcome header (+ setup steps until complete)
* - deployment live → deployed-Editor status header (+ steps until complete)
* - onboarding complete → header only; the setup steps collapse away
* - enterprise → status header with chips hidden (the deal hero owns invite)
*
* The footer is the deal-status hero while a procurement deal is underway
* (procurement is a bolt-on to any tier), otherwise the setup checklist. The
* procurement takeover flow modals render alongside so the hero's actions can
* open them.
* (procurement is a bolt-on to any tier); otherwise the setup checklist, until
* every step is done — then it collapses to just the header, matching the
* deployed-status card. The procurement takeover modals render alongside.
*/
export function HomeHero({ tier }: { tier: Tier }) {
const { openLinkModal } = useUI();
const procurement = useProcurement();
const progress = useOnboardingProgress();
const dealActive =
procurement.isLinked && procurement.started && !!procurement.data;
@@ -32,18 +35,26 @@ export function HomeHero({ tier }: { tier: Tier }) {
else openLinkModal();
};
// Steps collapse once onboarding is complete; a live deal always keeps its
// hero. Otherwise the setup checklist carries the (progress-aware) steps.
const footer = dealActive ? (
<ControlledDealStatusHero controller={procurement} />
) : (
<SetupChecklist onStartEnterprise={onStartEnterprise} />
) : progress.allComplete ? undefined : (
<SetupChecklist progress={progress} onStartEnterprise={onStartEnterprise} />
);
// The live-status header (EditorStatusCard) needs a real deployment to show;
// without one it renders nothing, so route to it only when actually deployed.
// Everything else — including a step completed via the local download flag —
// keeps the always-present welcome header, so the card never vanishes.
const showStatus = progress.deployed;
return (
<>
{tier === "free" ? (
<WelcomeBanner footer={footer} />
) : (
{showStatus ? (
<EditorStatusCard footer={footer} hideChips={tier === "enterprise"} />
) : (
<WelcomeBanner footer={footer} />
)}
<ProcurementFlow controller={procurement} />
</>
@@ -1,5 +1,5 @@
/* ──────────────────────────────────────────────────────────────────────── */
/* "Finish setting up" checklist (welcome-hero footer) */
/* Getting-started steps (home-hero body) */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-setup {
@@ -7,43 +7,6 @@
flex-direction: column;
}
.portal-setup__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6875rem 1.25rem 0.1875rem;
}
.portal-setup__eyebrow {
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-5);
}
/* Progress counter doubles as the dismiss affordance. */
.portal-setup__dismiss {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.1875rem 0.4375rem;
border: none;
border-radius: var(--radius-md);
background: transparent;
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-text-5);
cursor: pointer;
transition:
color var(--motion-fast),
background var(--motion-fast);
}
.portal-setup__dismiss:hover {
color: var(--color-text-2);
background: var(--color-bg-hover);
}
.portal-setup__list {
list-style: none;
margin: 0;
@@ -52,39 +15,46 @@
.portal-setup__row {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.875rem;
width: 100%;
padding: 0.875rem 1.25rem;
padding: 0.6875rem 1.25rem;
border: none;
border-top: 1px solid var(--color-border-light);
background: transparent;
text-align: left;
cursor: pointer;
transition: background var(--motion-fast);
}
.portal-setup__item:first-child .portal-setup__row {
border-top: none;
}
.portal-setup__row.is-interactive {
cursor: pointer;
}
.portal-setup__row.is-interactive:hover {
.portal-setup__row:hover {
background: var(--color-bg-hover);
}
.portal-setup__row:disabled {
cursor: default;
}
.portal-setup__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
/* Numbered step marker */
.portal-setup__num {
display: grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
background: var(--color-border-input);
border-radius: 50%;
border: 1px solid var(--color-border-input);
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-4);
}
.portal-setup__dot.is-done {
/* Completed step: filled green check. */
.portal-setup__num.is-done {
border-color: var(--color-green);
background: var(--color-green);
color: #fff;
}
.portal-setup__row.is-done .portal-setup__text strong {
color: var(--color-text-2);
}
.portal-setup__text {
@@ -93,52 +63,16 @@
min-width: 0;
}
.portal-setup__text strong {
font-size: 0.84375rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-setup__text span {
margin-top: 0.0625rem;
font-size: 0.75rem;
line-height: 1.4;
color: var(--color-text-4);
}
.portal-setup__chip {
flex-shrink: 0;
padding: 0.125rem 0.5625rem;
border-radius: var(--radius-pill);
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-text-5);
background: var(--color-bg-hover);
}
.portal-setup__chip.is-done {
color: var(--color-green-dark);
background: var(--color-green-light);
}
/* Loading skeleton */
.portal-setup__row.is-loading {
border-top: 1px solid var(--color-border-light);
}
.portal-setup__skel {
height: 0.75rem;
width: 60%;
border-radius: var(--radius-sm);
background: var(--color-bg-muted);
animation: portalSetupPulse 1.2s ease-in-out infinite;
}
@keyframes portalSetupPulse {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
/* ── Enterprise upsell rung ── */
.portal-setup__enterprise {
display: flex;
@@ -146,7 +80,7 @@
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 0.875rem 1.25rem;
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--color-border-light);
background: linear-gradient(
90deg,
@@ -1,11 +1,23 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { SetupChecklist } from "@portal/components/SetupChecklist";
import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress";
const base: OnboardingProgress = {
loading: false,
deployed: false,
editorDone: false,
policiesDone: false,
inviteDone: false,
policiesActive: 0,
policiesRecommended: 6,
allComplete: false,
};
const meta: Meta<typeof SetupChecklist> = {
title: "Portal/Home/SetupChecklist",
component: SetupChecklist,
parameters: { layout: "padded" },
args: { progress: base },
decorators: [
(S) => (
<div
@@ -25,34 +37,30 @@ const meta: Meta<typeof SetupChecklist> = {
export default meta;
type Story = StoryObj<typeof SetupChecklist>;
/** Completion + counts derived from the live policies / sources MSW fixtures. */
export const Default: Story = {};
/** A fresh workspace — no step complete yet. */
export const NotStarted: Story = {};
/** Slow policy/source fetch: shows the loading skeleton rows. */
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/policies", async () => {
await delay(100000);
return HttpResponse.json([]);
}),
],
/** Policies confirmed; editor + invite still open. */
export const InProgress: Story = {
args: {
progress: {
...base,
policiesDone: true,
policiesActive: 2,
policiesRecommended: 5,
},
},
};
/** A fresh workspace — no active policies or connected sources: every step reads "Not started". */
export const NotStarted: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/policies", () => HttpResponse.json([])),
http.get("/api/v1/policies/runs", () => HttpResponse.json([])),
http.get("/api/v1/sources", () =>
HttpResponse.json({ kpis: [], sources: [] }),
),
],
/** Editor deployed + policies on; only the invite step remains. */
export const AlmostDone: Story = {
args: {
progress: {
...base,
editorDone: true,
policiesDone: true,
policiesActive: 4,
policiesRecommended: 3,
},
},
};
@@ -1,17 +1,13 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useView, type ViewId } from "@portal/contexts/ViewContext";
import { useAsync } from "@portal/hooks/useAsync";
import { fetchPolicies, type PoliciesResponse } from "@portal/api/policies";
import { fetchSources, type SourcesResponse } from "@portal/api/sources";
import { CloseIcon } from "@portal/components/icons";
import { useView } from "@portal/contexts/ViewContext";
import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress";
import { DownloadEditorModal } from "@portal/components/DownloadEditorModal";
import CheckRounded from "@mui/icons-material/CheckRounded";
import "@portal/components/SetupChecklist.css";
/** Where the Stirling PDF Editor desktop app is downloaded. */
const EDITOR_DOWNLOAD_URL = "https://stirling.com/download";
/* ──────────────────────────────────────────────────────────────────────── */
/* Enterprise upsell rung */
/* ──────────────────────────────────────────────────────────────────────── */
@@ -59,173 +55,96 @@ function EnterpriseRung({
}
/* ──────────────────────────────────────────────────────────────────────── */
/* "Finish setting up" checklist */
/* Getting-started steps */
/* ──────────────────────────────────────────────────────────────────────── */
type StepAction =
| { kind: "external"; href: string }
| { kind: "navigate"; view: ViewId };
interface Step {
id: string;
title: string;
blurb: string;
done: boolean;
/** Status chip shown once the step is done. */
chip: string;
action: StepAction;
onClick: () => void;
}
/**
* Getting-started checklist, rendered as the attached footer of the home hero
* (welcome banner on free, deployed-Editor card on subscribed). Steps are a
* fixed do-this-then-that sequence with translatable copy; the completion
* state and counts are derived live from the org's real policies and sources
* (the same data the Policies / Sources pages show). The header doubles as a
* dismiss control; the Enterprise rung persists regardless.
* Numbered getting-started steps, rendered as the body of the home hero. Each
* row opens its in-app surface; a completed step (from {@link OnboardingProgress})
* swaps its number for a check. When every step is done the parent collapses the
* hero to the deployed-status header and stops rendering this list entirely.
*/
export function SetupChecklist({
progress,
onStartEnterprise,
}: {
progress: OnboardingProgress;
/** Start the enterprise flow in place (opens the setup modal over Home). Falls back to
* navigating to the procurement view when omitted (e.g. in isolated stories). */
onStartEnterprise?: () => void;
} = {}) {
}) {
const { t } = useTranslation();
const { tier } = useTier();
const { setActiveView } = useView();
const [dismissed, setDismissed] = useState(false);
const [downloadOpen, setDownloadOpen] = useState(false);
const state = useAsync<[PoliciesResponse, SourcesResponse]>(
() => Promise.all([fetchPolicies(), fetchSources()]),
[],
);
const { data, loading } = state;
const steps = useMemo<Step[]>(() => {
const [policies, sources] = data ?? [];
const active = policies?.summary.active ?? 0;
const recommended = Math.max(
0,
(policies?.summary.categories ?? 0) - active,
);
// A source counts as connected once it's set up and not disabled.
const connected =
sources?.sources.filter((s) => s.status !== "disabled").length ?? 0;
return [
{
id: "editor",
title: t("portal.home.onboarding.steps.editor.title"),
blurb: t("portal.home.onboarding.steps.editor.blurb"),
// "Deploy the desktop app" has no server signal — always an open task.
done: false,
chip: "",
action: { kind: "external", href: EDITOR_DOWNLOAD_URL },
},
{
id: "policies",
title: t("portal.home.onboarding.steps.policies.title"),
blurb: t("portal.home.onboarding.steps.policies.blurb", {
active,
recommended,
}),
done: active > 0,
chip: t("portal.home.onboarding.steps.policies.chip", { active }),
action: { kind: "navigate", view: "policies" },
},
{
id: "sources",
title: t("portal.home.onboarding.steps.sources.title"),
blurb: t("portal.home.onboarding.steps.sources.blurb", { connected }),
done: connected > 0,
chip: t("portal.home.onboarding.steps.sources.chip", { connected }),
action: { kind: "navigate", view: "sources" },
},
];
}, [data, t]);
const doneCount = steps.filter((s) => s.done).length;
function activate(step: Step) {
if (step.done) return;
if (step.action.kind === "external") {
window.open(step.action.href, "_blank", "noopener,noreferrer");
} else {
setActiveView(step.action.view);
}
}
const steps: Step[] = [
{
id: "editor",
title: t("portal.home.onboarding.steps.editor.title"),
blurb: t("portal.home.onboarding.steps.editor.blurb"),
done: progress.editorDone,
// Downloads are per-OS, so open the install picker rather than route away.
onClick: () => setDownloadOpen(true),
},
{
id: "policies",
title: t("portal.home.onboarding.steps.policies.title"),
blurb: t("portal.home.onboarding.steps.policies.blurb", {
active: progress.policiesActive,
recommended: progress.policiesRecommended,
}),
done: progress.policiesDone,
onClick: () => setActiveView("policies"),
},
{
id: "invite",
title: t("portal.home.onboarding.steps.invite.title"),
blurb: t("portal.home.onboarding.steps.invite.blurb"),
done: progress.inviteDone,
onClick: () => setActiveView("users"),
},
];
return (
<div className="portal-setup">
{loading && (
<div className="portal-setup__list" aria-hidden>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="portal-setup__row is-loading">
<span className="portal-setup__dot" />
<div className="portal-setup__text">
<div className="portal-setup__skel" />
</div>
</div>
))}
</div>
)}
{!loading && !dismissed && (
<>
<div className="portal-setup__head">
<span className="portal-setup__eyebrow">
{t("portal.home.onboarding.title")}
</span>
<ol className="portal-setup__list">
{steps.map((s, i) => (
<li key={s.id} className="portal-setup__item">
<button
type="button"
className="portal-setup__dismiss"
onClick={() => setDismissed(true)}
title={t("portal.home.onboarding.dismiss")}
className={"portal-setup__row" + (s.done ? " is-done" : "")}
onClick={s.onClick}
>
{t("portal.home.onboarding.progress", {
done: doneCount,
total: steps.length,
})}
<CloseIcon size={13} />
<span
className={"portal-setup__num" + (s.done ? " is-done" : "")}
aria-hidden
>
{s.done ? <CheckRounded sx={{ fontSize: 16 }} /> : i + 1}
</span>
<span className="portal-setup__text">
<strong>{s.title}</strong>
<span>{s.blurb}</span>
</span>
</button>
</div>
<ol className="portal-setup__list">
{steps.map((s) => (
<li key={s.id} className="portal-setup__item">
<button
type="button"
className={
"portal-setup__row" +
(s.done ? " is-done" : " is-interactive")
}
onClick={() => activate(s)}
disabled={s.done}
>
<span
className={"portal-setup__dot" + (s.done ? " is-done" : "")}
aria-hidden
/>
<span className="portal-setup__text">
<strong>{s.title}</strong>
<span>{s.blurb}</span>
</span>
<span
className={
"portal-setup__chip" + (s.done ? " is-done" : "")
}
>
{s.done ? s.chip : t("portal.home.onboarding.notStarted")}
</span>
</button>
</li>
))}
</ol>
</>
)}
</li>
))}
</ol>
<EnterpriseRung paying={tier !== "free"} onStart={onStartEnterprise} />
<DownloadEditorModal
open={downloadOpen}
onClose={() => setDownloadOpen(false)}
/>
</div>
);
}
@@ -1,461 +1,103 @@
/* ──────────────────────────────────────────────────────────────────────── */
/* Free-tier welcome hero */
/* Free-tier welcome hero — compact product header + steps */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-welcome {
border-radius: var(--radius-xl);
border: 1px solid var(--color-blue-border);
border: 1px solid var(--color-border-light);
overflow: hidden;
isolation: isolate;
}
.portal-welcome__body {
position: relative;
display: flex;
align-items: stretch;
min-height: 22.5rem;
background: linear-gradient(135deg, #eaf2fb 0%, #eef3fb 50%, #f4f0fa 100%);
}
[data-theme="dark"] .portal-welcome__body {
background: linear-gradient(135deg, #0e0e10 0%, #121214 50%, #161618 100%);
}
/* ── Flowing gradient canvas: three radial blobs drifting at different
speeds for a slow, organic color flow. Purely decorative. ── */
.portal-welcome__canvas {
position: absolute;
inset: -40%;
width: 180%;
height: 180%;
filter: blur(3.75rem);
pointer-events: none;
z-index: 0;
}
.portal-welcome__blob {
position: absolute;
border-radius: 50%;
}
.portal-welcome__blob--1 {
width: 60%;
height: 70%;
top: -10%;
left: 30%;
background: radial-gradient(
circle,
rgba(59, 130, 246, 0.35) 0%,
rgba(99, 102, 241, 0.2) 40%,
transparent 70%
);
animation: portalHeroBlob1 5s ease-in-out infinite;
}
.portal-welcome__blob--2 {
width: 55%;
height: 60%;
top: 10%;
right: -15%;
background: radial-gradient(
circle,
rgba(139, 92, 246, 0.4) 0%,
rgba(192, 132, 252, 0.2) 40%,
transparent 70%
);
animation: portalHeroBlob2 7s ease-in-out infinite;
}
.portal-welcome__blob--3 {
width: 50%;
height: 55%;
bottom: -15%;
left: 35%;
background: radial-gradient(
circle,
rgba(59, 130, 246, 0.25) 0%,
rgba(14, 165, 233, 0.15) 40%,
transparent 70%
);
animation: portalHeroBlob3 9s ease-in-out infinite;
}
[data-theme="dark"] .portal-welcome__blob--1 {
left: 20%;
background: radial-gradient(
circle,
rgba(96, 165, 250, 0.4) 0%,
rgba(99, 102, 241, 0.25) 40%,
transparent 70%
);
}
[data-theme="dark"] .portal-welcome__blob--2 {
background: radial-gradient(
circle,
rgba(167, 139, 250, 0.45) 0%,
rgba(192, 132, 252, 0.25) 40%,
transparent 70%
);
}
[data-theme="dark"] .portal-welcome__blob--3 {
left: 20%;
background: radial-gradient(
circle,
rgba(96, 165, 250, 0.3) 0%,
rgba(56, 189, 248, 0.18) 40%,
transparent 70%
);
}
@keyframes portalHeroBlob1 {
0% {
transform: translate(0%, 0%) scale(1);
}
33% {
transform: translate(18%, -25%) scale(1.15);
}
66% {
transform: translate(-10%, 18%) scale(0.9);
}
100% {
transform: translate(0%, 0%) scale(1);
}
}
@keyframes portalHeroBlob2 {
0% {
transform: translate(0%, 0%) scale(1);
}
33% {
transform: translate(-20%, 15%) scale(1.1);
}
66% {
transform: translate(15%, -20%) scale(1.15);
}
100% {
transform: translate(0%, 0%) scale(1);
}
}
@keyframes portalHeroBlob3 {
0% {
transform: translate(0%, 0%) scale(1.05);
}
33% {
transform: translate(22%, 10%) scale(0.9);
}
66% {
transform: translate(-15%, -15%) scale(1.15);
}
100% {
transform: translate(0%, 0%) scale(1.05);
}
}
@media (prefers-reduced-motion: reduce) {
.portal-welcome__blob {
animation: none;
}
}
/* ── Left column: copy + CTAs ── */
.portal-welcome__text {
position: relative;
z-index: 1;
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: 2.75rem 0 2.75rem 3rem;
}
.portal-welcome__badge {
align-self: flex-start;
margin-bottom: 1rem;
padding: 0.3125rem 0.875rem;
border-radius: var(--radius-pill);
border: 1px solid var(--color-blue-border);
background: var(--color-surface);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-blue);
}
.portal-welcome__title {
margin: 0 0 1.125rem;
font-size: 2.25rem;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.025em;
color: var(--color-text-1);
}
.portal-welcome__title-accent {
color: var(--color-blue);
}
.portal-welcome__sub {
margin: 0 0 1.75rem;
max-width: 32.5rem;
font-size: 1rem;
line-height: 1.6;
color: var(--color-text-3);
}
.portal-welcome__cta {
/* ── Dark product header strip ── */
.portal-welcome__header {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 0.875rem 1.25rem;
background: #16213e;
}
.portal-welcome__brand {
display: flex;
align-items: center;
gap: 0.875rem;
min-width: 0;
}
.portal-welcome__mark {
display: grid;
place-items: center;
flex-shrink: 0;
}
.portal-welcome__mark img {
display: block;
height: 1.75rem;
width: auto;
}
.portal-welcome__brand-text {
display: flex;
align-items: baseline;
gap: 0.625rem;
min-width: 0;
flex-wrap: wrap;
}
.portal-welcome__perks {
margin: 1.375rem 0 0;
font-size: 0.78125rem;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--color-text-4);
.portal-welcome__product {
font-size: 1.125rem;
font-weight: 700;
letter-spacing: -0.01em;
color: #fff;
}
/* ── Right column: editor-card visual ── */
.portal-welcome__visual {
position: relative;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: flex-end;
flex: none;
padding-left: 1.5rem;
}
@media (max-width: 60rem) {
.portal-welcome__visual {
display: none;
}
.portal-welcome__text {
padding: 2.5rem 2rem;
}
}
/* ── Dark editor card ── */
.portal-welcome__editor {
align-self: flex-end;
width: 38.75rem;
max-width: 100%;
margin-right: 2.5rem;
min-height: 18.75rem;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 0.75rem 0.75rem 0 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: none;
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(0.75rem);
box-shadow:
0 0.5rem 2rem rgba(0, 0, 0, 0.25),
0 0.125rem 0.5rem rgba(0, 0, 0, 0.15);
}
.portal-welcome__editor-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.portal-welcome__editor-dots {
display: flex;
gap: 0.375rem;
flex-shrink: 0;
}
.portal-welcome__editor-dots span {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.portal-welcome__editor-dots span:nth-child(1) {
background: #ef4444;
}
.portal-welcome__editor-dots span:nth-child(2) {
background: #f59e0b;
}
.portal-welcome__editor-dots span:nth-child(3) {
background: #22c55e;
}
.portal-welcome__editor-file {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
.portal-welcome__stats {
min-width: 0;
font-size: 0.6875rem;
color: rgba(255, 255, 255, 0.6);
}
.portal-welcome__editor-name {
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal-welcome__editor-sep {
color: rgba(255, 255, 255, 0.35);
flex-shrink: 0;
}
.portal-welcome__editor-pages {
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.55);
flex-shrink: 0;
}
.portal-welcome__editor-page {
font-size: 0.6875rem;
color: rgba(255, 255, 255, 0.55);
flex-shrink: 0;
}
.portal-welcome__editor-body {
display: flex;
align-items: stretch;
flex: 1;
min-height: 0;
}
.portal-welcome__rail {
width: 2.5rem;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0;
color: rgba(255, 255, 255, 0.4);
}
.portal-welcome__editor-body > .portal-welcome__rail:first-child {
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
.portal-welcome__editor-body > .portal-welcome__rail:last-child {
border-left: 1px solid rgba(255, 255, 255, 0.06);
}
.portal-welcome__rail-icon {
/* Header action group: icon buttons + the CTA. */
.portal-welcome__actions {
display: flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border-radius: 0.3125rem;
gap: 0.5rem;
}
.portal-welcome__rail-icon:nth-child(2) {
background: rgba(96, 165, 250, 0.18);
color: rgba(147, 197, 253, 0.95);
.portal-welcome__icon-btn {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
flex-shrink: 0;
border-radius: var(--radius-md);
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.08);
color: #fff;
cursor: pointer;
transition: background var(--motion-fast);
}
.portal-welcome__icon-btn:hover {
background: rgba(255, 255, 255, 0.16);
}
/* ── White document preview ── */
.portal-welcome__doc {
flex: 1;
min-width: 0;
margin: 0.75rem 1rem 0;
padding: 0.875rem 1.125rem;
border-radius: 0.25rem 0.25rem 0 0;
/* White CTA on the dark header, matching the marketing card. */
.portal-welcome__header .portal-welcome__cta.sui-btn {
background: #ffffff;
color: #0f172a;
box-shadow: 0 0.25rem 1.125rem rgba(0, 0, 0, 0.22);
border-color: #ffffff;
color: #16213e;
}
.portal-welcome__header .portal-welcome__cta.sui-btn:hover {
background: rgba(255, 255, 255, 0.88);
border-color: rgba(255, 255, 255, 0.88);
}
.portal-welcome__doc-eyebrow {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.5625rem;
font-weight: 700;
letter-spacing: 0.08em;
color: #dc2626;
}
.portal-welcome__doc-critical {
padding: 0.1875rem 0.5625rem;
border-radius: 0.1875rem;
background: #fee2e2;
letter-spacing: 0.06em;
}
.portal-welcome__doc-title {
margin-bottom: 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
line-height: 1.3;
color: #0f172a;
}
.portal-welcome__doc-divider {
border-top: 1px solid #e8e6e1;
margin-bottom: 0.625rem;
}
.portal-welcome__doc-meta {
display: flex;
gap: 1.5rem;
margin-bottom: 0.75rem;
font-size: 0.625rem;
color: #0f172a;
font-weight: 600;
}
.portal-welcome__doc-meta-key {
color: #9b9a97;
font-weight: 400;
}
.portal-welcome__doc-heading {
margin-bottom: 0.375rem;
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.06em;
color: #0f172a;
}
.portal-welcome__doc-body {
margin: 0 0 0.75rem;
font-size: 0.625rem;
line-height: 1.5;
color: #6b6b6b;
}
.portal-welcome__doc-finding {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.4375rem 0.75rem;
border-radius: 0.1875rem;
font-size: 0.625rem;
font-weight: 500;
}
.portal-welcome__doc-finding--critical {
margin-bottom: 0.3125rem;
background: #fee2e2;
color: #7f1d1d;
}
.portal-welcome__doc-finding--high {
background: #fef3c7;
color: #92400e;
}
.portal-welcome__doc-dot {
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
flex-shrink: 0;
}
.portal-welcome__doc-finding--critical .portal-welcome__doc-dot {
background: #dc2626;
}
.portal-welcome__doc-finding--high .portal-welcome__doc-dot {
background: #d97706;
}
/* ── Attached footer strip (setup checklist) ── */
/* ── Steps + enterprise (setup checklist) sit directly under the header ── */
.portal-welcome__footer {
position: relative;
z-index: 2;
border-top: 1px solid var(--color-border-light);
background: var(--color-surface);
}
@@ -1,6 +1,18 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { WelcomeBanner } from "@portal/components/WelcomeBanner";
import { SetupChecklist } from "@portal/components/SetupChecklist";
import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress";
const progress: OnboardingProgress = {
loading: false,
deployed: false,
editorDone: false,
policiesDone: true,
inviteDone: false,
policiesActive: 2,
policiesRecommended: 5,
allComplete: false,
};
const meta: Meta<typeof WelcomeBanner> = {
title: "Portal/Home/WelcomeBanner",
@@ -24,6 +36,6 @@ export const Default: Story = {};
* checklist attached as the footer strip. */
export const WithSetupChecklist: Story = {
args: {
footer: <SetupChecklist />,
footer: <SetupChecklist progress={progress} />,
},
};
@@ -1,129 +1,31 @@
import type { ReactNode } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import { useView } from "@portal/contexts/ViewContext";
import { EDITOR_URL } from "@portal/auth/editorUrl";
import {
DownloadIcon,
UsersIcon,
EditorIcon,
ExternalLinkIcon,
SearchIcon,
SourcesIcon,
PoliciesIcon,
SparklesIcon,
ComponentsIcon,
DocumentsIcon,
SettingsIcon,
UserPlusIcon,
} from "@portal/components/icons";
import { DownloadEditorModal } from "@portal/components/DownloadEditorModal";
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
import "@portal/components/WelcomeBanner.css";
/* ──────────────────────────────────────────────────────────────────────── */
/* Editor-card visual */
/* Free-tier welcome hero */
/* */
/* A decorative mock of the deployed PDF Editor reviewing a security */
/* report. Purely presentational — hidden from assistive tech. */
/* ──────────────────────────────────────────────────────────────────────── */
const RAIL_ICONS = [
SearchIcon,
SourcesIcon,
PoliciesIcon,
EditorIcon,
ComponentsIcon,
SparklesIcon,
DocumentsIcon,
SettingsIcon,
] as const;
function EditorRail() {
return (
<div className="portal-welcome__rail" aria-hidden>
{RAIL_ICONS.map((Icon, i) => (
<span key={i} className="portal-welcome__rail-icon">
<Icon size={13} />
</span>
))}
</div>
);
}
function WelcomeEditorCard() {
return (
<div className="portal-welcome__editor" aria-hidden>
<div className="portal-welcome__editor-bar">
<div className="portal-welcome__editor-dots">
<span />
<span />
<span />
</div>
<div className="portal-welcome__editor-file">
<EditorIcon size={12} />
<span className="portal-welcome__editor-name">
Vulnerability Assessment Report CVE-2026-1847.pdf
</span>
<span className="portal-welcome__editor-sep">·</span>
<span className="portal-welcome__editor-pages">18 pages</span>
</div>
<span className="portal-welcome__editor-page">1 / 18</span>
</div>
<div className="portal-welcome__editor-body">
<EditorRail />
<div className="portal-welcome__doc">
<div className="portal-welcome__doc-eyebrow">
<span>SECURITY ASSESSMENT</span>
<span className="portal-welcome__doc-critical">CRITICAL</span>
</div>
<div className="portal-welcome__doc-title">
Vulnerability Assessment Report CVE-2026-1847
</div>
<div className="portal-welcome__doc-divider" />
<div className="portal-welcome__doc-meta">
<span>
<span className="portal-welcome__doc-meta-key">
Assessment Date:{" "}
</span>
February 2026
</span>
<span>
<span className="portal-welcome__doc-meta-key">
Classification:{" "}
</span>
FOUO
</span>
</div>
<div className="portal-welcome__doc-heading">EXECUTIVE SUMMARY</div>
<p className="portal-welcome__doc-body">
This assessment identifies critical vulnerabilities in the target
environment and provides actionable remediation guidance prioritized
by risk severity.
</p>
<div className="portal-welcome__doc-heading">KEY FINDINGS</div>
<div className="portal-welcome__doc-finding portal-welcome__doc-finding--critical">
<span className="portal-welcome__doc-dot" />3 Critical
vulnerabilities identified
</div>
<div className="portal-welcome__doc-finding portal-welcome__doc-finding--high">
<span className="portal-welcome__doc-dot" />7 High-severity issues
requiring attention
</div>
</div>
<EditorRail />
</div>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Welcome banner (free-tier hero) */
/* A compact product header — brand mark, "PDF Editor" + social-proof */
/* stats, and a single "Open in browser" CTA — over the getting-started */
/* steps (passed in as {@code footer}). Deliberately lean: the onboarding */
/* steps, not marketing copy, are the point of the card. */
/* ──────────────────────────────────────────────────────────────────────── */
interface WelcomeBannerProps {
/**
* Rendered as an attached footer strip inside the banner card (e.g. the
* "Finish setting up" checklist). Kept as a slot so the hero stays a pure
* presentational shell.
* The getting-started steps + enterprise rung, rendered inside the card
* below the header. Kept as a slot so the hero stays a presentational shell.
*/
footer?: ReactNode;
}
@@ -131,64 +33,65 @@ interface WelcomeBannerProps {
export function WelcomeBanner({ footer }: WelcomeBannerProps) {
const { t } = useTranslation();
const { setActiveView } = useView();
const [installOpen, setInstallOpen] = useState(false);
return (
<section
className="portal-welcome"
aria-label={t("portal.welcome.ariaLabel")}
>
<div className="portal-welcome__body">
<div className="portal-welcome__canvas" aria-hidden>
<div className="portal-welcome__blob portal-welcome__blob--1" />
<div className="portal-welcome__blob portal-welcome__blob--2" />
<div className="portal-welcome__blob portal-welcome__blob--3" />
</div>
<div className="portal-welcome__text">
<span className="portal-welcome__badge">
{t("portal.welcome.badge")}
<div className="portal-welcome__header">
<div className="portal-welcome__brand">
<span className="portal-welcome__mark" aria-hidden>
<img src={markDark} alt="" />
</span>
<h1 className="portal-welcome__title">
{t("portal.welcome.title")}{" "}
<span className="portal-welcome__title-accent">
{t("portal.welcome.titleAccent")}
<div className="portal-welcome__brand-text">
<span className="portal-welcome__product">
{t("portal.welcome.productName")}
</span>
<span className="portal-welcome__stats">
{t("portal.welcome.stats")}
</span>
</h1>
<p className="portal-welcome__sub">{t("portal.welcome.subtitle")}</p>
<div className="portal-welcome__cta">
<Button
variant="primary"
leftSection={<ExternalLinkIcon size={15} />}
onClick={() => {
window.location.href = EDITOR_URL;
}}
>
{t("portal.welcome.openInBrowser")}
</Button>
<Button
variant="secondary"
leftSection={<DownloadIcon size={15} />}
onClick={() => setActiveView("editor")}
>
{t("portal.welcome.installEditor")}
</Button>
<Button
variant="tertiary"
leftSection={<UsersIcon size={15} />}
onClick={() => setActiveView("users")}
>
{t("portal.welcome.inviteTeammates")}
</Button>
</div>
<p className="portal-welcome__perks">{t("portal.welcome.perks")}</p>
</div>
<div className="portal-welcome__visual">
<WelcomeEditorCard />
<div className="portal-welcome__actions">
<button
type="button"
className="portal-welcome__icon-btn"
onClick={() => setActiveView("users")}
aria-label={t("portal.welcome.invite")}
title={t("portal.welcome.invite")}
>
<UserPlusIcon size={16} />
</button>
<button
type="button"
className="portal-welcome__icon-btn"
onClick={() => setInstallOpen(true)}
aria-label={t("portal.welcome.install")}
title={t("portal.welcome.install")}
>
<DownloadIcon size={16} />
</button>
<Button
variant="primary"
className="portal-welcome__cta"
leftSection={<ExternalLinkIcon size={15} />}
onClick={() => {
window.location.href = EDITOR_URL;
}}
>
{t("portal.welcome.openInBrowser")}
</Button>
</div>
</div>
{footer && <div className="portal-welcome__footer">{footer}</div>}
<DownloadEditorModal
open={installOpen}
onClose={() => setInstallOpen(false)}
/>
</section>
);
}
@@ -36,10 +36,7 @@ export function EnterpriseUpsell({ bare = false }: Props) {
</p>
</div>
<Button size="sm" onClick={() => setActiveView("procurement")}>
{t(
"portal.billing.enterpriseUpsell.cta",
"Build your Enterprise quote",
)}
{t("portal.billing.enterpriseUpsell.cta", "Explore Enterprise")}
</Button>
</div>
</>
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
/**
* A local "the user has grabbed the editor" flag for the getting-started
* download step. The deployment endpoint is the authoritative signal, but it
* only lights up once an instance reports in — so we also let the user's own
* action (clicking a download button / pressing Done in the install modal) mark
* the step complete immediately. Persisted so it survives reloads.
*/
const KEY = "stirling.portal.editorInstalled";
const EVENT = "stirling:editor-installed";
export function markEditorInstalled(): void {
try {
localStorage.setItem(KEY, "1");
} catch {
// Private mode / storage disabled — the step just won't stick; harmless.
}
window.dispatchEvent(new Event(EVENT));
}
export function useEditorInstalled(): boolean {
const [installed, setInstalled] = useState<boolean>(() => {
try {
return localStorage.getItem(KEY) === "1";
} catch {
return false;
}
});
useEffect(() => {
const sync = () => {
try {
setInstalled(localStorage.getItem(KEY) === "1");
} catch {
// ignore
}
};
// Same-tab mark fires EVENT; other tabs fire the native storage event.
window.addEventListener(EVENT, sync);
window.addEventListener("storage", sync);
return () => {
window.removeEventListener(EVENT, sync);
window.removeEventListener("storage", sync);
};
}, []);
return installed;
}
@@ -0,0 +1,68 @@
import { useTier } from "@portal/contexts/TierContext";
import { useAsync } from "@portal/hooks/useAsync";
import { useEditorInstalled } from "@portal/hooks/useEditorInstalled";
import { fetchEditorDeployment } from "@portal/api/editorDeploy";
import { fetchPolicies } from "@portal/api/policies";
import { fetchUsers } from "@portal/api/users";
/**
* Getting-started completion, derived live from the org's real state. Drives
* both the per-step checks on the home hero's setup steps and the collapse to
* the deployed-status header once every step is done.
*
* Each fetch is independently best-effort: an endpoint that isn't served yet
* (e.g. editor-deployment on a bare backend) simply leaves its step incomplete
* rather than breaking the card.
*/
export interface OnboardingProgress {
loading: boolean;
/** A real deployment is reporting in (drives the live-status header). */
deployed: boolean;
/** Download step done — a real deployment OR the user's own download/Done. */
editorDone: boolean;
policiesDone: boolean;
/** More than just the admin on the team. */
inviteDone: boolean;
policiesActive: number;
policiesRecommended: number;
allComplete: boolean;
}
export function useOnboardingProgress(): OnboardingProgress {
const { tier } = useTier();
const editorInstalled = useEditorInstalled();
const { data, loading } = useAsync(
() =>
Promise.all([
fetchEditorDeployment(tier).catch(() => null),
fetchPolicies().catch(() => null),
fetchUsers(tier).catch(() => null),
]),
[tier],
);
const [deploy, policies, users] = data ?? [null, null, null];
const deployed = (deploy?.instances.length ?? 0) > 0;
// Authoritative signal is a deployed instance; the user's own download/Done
// action marks it complete immediately via the persisted flag.
const editorDone = editorInstalled || deployed;
const policiesActive = policies?.summary.active ?? 0;
const policiesRecommended = Math.max(
0,
(policies?.summary.categories ?? 0) - policiesActive,
);
const policiesDone = policiesActive > 0;
const inviteDone = (users?.summary.totalMembers ?? 0) > 1;
return {
loading,
deployed,
editorDone,
policiesDone,
inviteDone,
policiesActive,
policiesRecommended,
allComplete: editorDone && policiesDone && inviteDone,
};
}
+8 -5
View File
@@ -251,15 +251,18 @@ export default defineConfig(
"no-restricted-syntax": ["error", ...mantineComponentImportRestrictions],
},
},
// TEMPORARY (same rationale as procurement above): the portal home hero
// reuses the same bespoke CSS-styled raw <button> controls as the procurement
// deal hero — status/invite chips and full-width checklist rows that the
// shared Button can't represent. Exempt ONLY the raw-<button> rule; the
// Mantine import bans stay. Migrate these alongside the procurement buttons.
// TEMPORARY (same rationale as procurement above): the portal home hero +
// install modal reuse the same bespoke CSS-styled raw <button> controls as the
// procurement deal hero — status/invite/icon buttons, full-width checklist and
// install-option rows, and link-style guide actions that the shared Button
// can't represent. Exempt ONLY the raw-<button> rule; the Mantine import bans
// stay. Migrate these alongside the procurement buttons.
{
files: [
"editor/src/portal/components/EditorStatusCard.tsx",
"editor/src/portal/components/SetupChecklist.tsx",
"editor/src/portal/components/WelcomeBanner.tsx",
"editor/src/portal/components/DownloadEditorModal.tsx",
],
rules: {
"no-restricted-syntax": ["error", ...mantineComponentImportRestrictions],