mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user