Compare commits

...
Author SHA1 Message Date
Connor Yoh 68f851e31f copy(portal): reword billing Enterprise CTA to "Explore Enterprise"
Was "Build your Enterprise quote" on the Usage & Billing enterprise upsell.
2026-07-10 11:47:11 +01:00
Connor Yoh ad04260f53 Merge remote-tracking branch 'origin/main' into claude/vigorous-euler-bd6445
# Conflicts:
#	frontend/editor/src/portal/components/procurement/ProcurementHome.tsx
2026-07-08 14:19:29 +01:00
Connor Yoh e1dfa5b05a feat(portal): gracefully hide editor hero when deployment endpoint is unavailable
/v1/editor/deployment isn't implemented on every backend yet. When it returns
no data (e.g. a 404 on a real server), skip the status row and fall back to
just the setup checklist instead of a stuck skeleton. It shows in full wherever
the endpoint is served (mock/dev today, real backend later) — no flag needed.

Add a DeploymentUnavailable story covering the fallback.
2026-07-08 13:09:34 +01:00
Connor Yoh 97de031a04 fix(portal): drop editor-hero deployment label when it repeats the host
The managed-cloud instance is named "Managed Cloud", so the hero meta line
read "Managed Cloud · Managed Cloud · …". Skip the target label when it
equals the host.
2026-07-08 11:20:09 +01:00
Connor Yoh 7aaf6ba754 Merge origin/main into claude/vigorous-euler-bd6445
Resolve conflicts:
- WelcomeCarousel.tsx: keep deleted (replaced by WelcomeBanner)
- Home.tsx: keep HomeHero composition; drop main's re-added FreeOnboarding
- ProcurementHome.tsx: keep the refactored useProcurement/banner/flow split

Migrate new portal-home components to main's new Button API
(variant primary/secondary, accent default/premium, left/rightSection),
and add a documented raw-<button> lint exemption for the hero's bespoke
chip/checklist controls (same rationale as the procurement feature's).
2026-07-08 10:38:06 +01:00
Connor Yoh 4d4de9fffa feat(portal): wire setup checklist to live policies/sources + i18n step copy
The "Finish setting up" checklist was fixture-driven with hardcoded counts and
done-states. Make it real and fully translatable:

- SetupChecklist builds its 3 steps client-side and derives completion + counts
  from the live APIs: fetchPolicies() (summary.active / recommended) and
  fetchSources() (connected = non-disabled sources). Done = count > 0.
- Step copy (titles, blurbs, chips) moves into i18n under
  portal.home.onboarding.steps.* with count interpolation; the editor-target
  labels move to portal.home.editor.target.*.
- "Download the PDF Editor" now opens https://stirling.com/download (external);
  policies/sources rows navigate to their views as before.
- Remove the obsolete OnboardingStep type, FREE/PRO_ONBOARDING fixtures, the
  /v1/onboarding endpoint + handler, and fetchOnboarding — nothing else used them.
- SetupChecklist no longer needs onTryOp; drop it from HomeHero too.

Storybook: Default derives 2/3 from the policies/sources fixtures; NotStarted
seeds empty data → 0/3.
2026-07-07 15:08:48 +01:00
Connor Yoh 8ddf2e41e2 fix(portal): subscribed hero reuses real editor-deployment API (was shadowing it)
The subscribed hero shipped its own /v1/editor/deployment mock + handler, which
was registered before editorDeployHandlers and shadowed the real endpoint —
breaking the Editor admin view (it received the hero's mini payload).

- EditorStatusCard now reads the shared fetchEditorDeployment(tier)
  (EditorDeploymentResponse): headlines the busiest healthy instance for
  host/version/region/target, sums live users for the chip, and opens the
  workspace URL.
- Add workspaceUrl to DeploymentSummary so "Open in browser" has a real target.
- Remove the duplicate EditorDeployment type, EDITOR_DEPLOYMENT fixture,
  home-layer fetchEditorDeployment, and the colliding handler.
- i18n: add portal.home.editor.name / .updated.

Note: the onboarding checklist counts/done-state remain fixture data (not yet
wired to fetchPolicies/fetchSources); step copy also lives in the fixture.
2026-07-07 14:53:21 +01:00
Connor Yoh f947e8e7c1 test(portal): add SubscribedInProcurement Home story (deal hero in footer) 2026-07-07 14:35:26 +01:00
Connor Yoh e60c271f30 refactor(portal): procurement as hero footer bolt-on; enterprise reuses editor hero
Compose the Home hero per tier with a procurement-aware footer, matching the
prototype across all states:

- Extract ProcurementHome's state/actions into a useProcurement() controller
  hook, and split its UI into ProcurementBanner (deal hero / on-ramp) and
  ProcurementFlow (takeover modals). ProcurementHome now composes these; the
  /procurement route is unchanged.
- New HomeHero: renders the tier hero (free = welcome banner, pro/enterprise =
  deployed-Editor card) and swaps its footer to the deal-status hero while a
  procurement deal is underway, otherwise the setup checklist.
- Enterprise reuses the editor hero with chips hidden (the deal hero owns the
  invite action), per the prototype.
- The setup checklist's Enterprise rung is the sole no-deal on-ramp on Home;
  ProcurementHome's standalone upsell no longer renders there (removing the
  duplicate CTA).
2026-07-07 14:26:09 +01:00
Connor Yoh 2372282e3d feat(portal): subscribed-tier deployed-Editor hero + paid greeting
Add the subscribed (Processor) home hero from the marketing prototype:

- EditorStatusCard: a deployed-Editor status hero (brand mark, active-user
  + invite chips, deployment meta line, single "Open in browser" action)
  with the setup checklist attached as its footer.
- HomeGreeting: time-of-day greeting + date above the paid-tier home.
- SetupChecklist is now tier-aware: pro shows the Processor checklist
  (4 active / 7 connected) and a "Get Quote" Enterprise rung; free keeps
  "Start Trial".
- Fixtures/endpoints: EDITOR_DEPLOYMENT (/v1/editor/deployment),
  PRO_ONBOARDING, tier-aware /v1/onboarding.
- Rename the welcome hero's CSS namespace portal-hero -> portal-welcome to
  avoid colliding with the procurement DealStatusHero's .portal-hero (both
  are global stylesheets after the main merge).

Home now composes per tier: free = welcome banner, pro = deployed-Editor
hero, both with the checklist footer; ProcurementHome stays a bolt-on.
Adds icons (UserPlus, ExternalLink) and an EditorStatusCard story.
2026-07-07 14:14:14 +01:00
Connor Yoh 79cddfd800 Merge remote-tracking branch 'origin/main' into claude/vigorous-euler-bd6445
# Conflicts:
#	frontend/editor/src/portal/views/Home.tsx
2026-07-07 13:59:52 +01:00
Connor Yoh 8d97cff8eb feat(portal): replace free-tier carousel with static welcome hero
Rework the free-tier portal home to match the marketing prototype:

- Add a static WelcomeBanner hero (OPEN-SOURCE badge, brand-accented
  title, Install the Editor / Invite teammates CTAs, perks line, animated
  gradient blobs, and a decorative editor-card visual). Replaces the
  rotating WelcomeCarousel, which is removed.
- Add a SetupChecklist ("Finish setting up") rendered as the hero's
  attached footer: status-dot rows with trailing chips, a dismissible
  progress header, and a persistent Enterprise upsell rung.
- Update the free-tier onboarding fixture to the demo's three steps and
  extend OnboardingStep with a status chip.
- Pro/Enterprise homes drop the welcome hero (matching the prototype) and
  keep their dashboards.

Adds Storybook stories for both new components and a reusable DownloadIcon.
2026-07-07 13:40:16 +01:00
30 changed files with 2178 additions and 1074 deletions
@@ -6262,7 +6262,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"
@@ -6739,6 +6739,23 @@ ready = "Ready to deploy"
description = "Once documents are processed, your 30-day usage appears here."
title = "No usage yet"
[portal.home.editor]
activeUsers = "{{n}} active"
invite = "Invite teammates"
name = "Stirling PDF Editor"
open = "Open in browser"
updated = "updated {{time}}"
[portal.home.editor.target]
cloud = "Managed Cloud"
docker = "Self-hosted · Docker"
kubernetes = "Self-hosted · Kubernetes"
[portal.home.greeting]
afternoon = "Good afternoon"
evening = "Good evening"
morning = "Good morning"
[portal.home.kpis.enterprise]
docs30d = "Docs / 30d"
evalPassRate = "Eval pass rate"
@@ -6759,15 +6776,31 @@ evalPassRate = "Eval pass rate"
pipelines = "Pipelines"
[portal.home.onboarding]
progress = "{{done}} / {{total}} done"
runAgain = "Run again"
start = "Start"
subtitle = "Four steps to a production-shaped Stirling project."
title = "Get to value"
dismiss = "Dismiss setup"
notStarted = "Not started"
progress = "{{done}} of {{total}} done"
title = "Finish setting up"
[portal.home.onboarding.empty]
description = "Onboarding tasks will appear here once your workspace is set up."
title = "No onboarding steps yet"
[portal.home.onboarding.enterprise]
body = "Org-wide SSO + SCIM + RBAC, committed volume pricing, and air-gapped deployment."
cta = "Start Trial"
ctaQuote = "Get Quote"
lead = "For 250+ employees."
tag = "Enterprise"
[portal.home.onboarding.steps.editor]
blurb = "Deploy the desktop app, free forever."
title = "Download the PDF Editor"
[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"
[portal.home.productGrid]
ariaLabel = "Process PDFs at scale"
@@ -8015,36 +8048,14 @@ status = "Status"
suspend = "Suspend"
[portal.welcome]
ariaLabel = "Stirling product highlights"
pagination = "Carousel pagination"
slideLabel = "Slide {{number}}: {{title}}"
[portal.welcome.ornament.editor]
critical = "Critical"
ocrClean = "OCR-clean"
schemaMatch = "schema match 0.97"
signed = "signed"
[portal.welcome.slides.agents]
eyebrow = "AI Agents"
primary = "Try PDF Processor"
secondary = "View MCP docs"
sub = "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship."
title = "PDF Processor for AI Agents"
[portal.welcome.slides.editor]
eyebrow = "PDF Editor"
primary = "Install PDF Editor"
secondary = "Connect an instance"
sub = "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane."
title = "The #1 PDF Editor on GitHub"
[portal.welcome.slides.platform]
eyebrow = "Platform"
primary = "Try a PDF operation"
secondary = "Get an API key"
sub = "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls."
title = "PDF Infrastructure for Developers"
ariaLabel = "Welcome to Stirling PDF"
badge = "Open-source"
installEditor = "Install the Editor"
inviteTeammates = "Invite teammates"
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"
[printFile]
title = "Print File"
-1
View File
@@ -52,7 +52,6 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
| Home | `GET /v1/analytics/usage` | — | `fetchUsageSeries` | `UsageSeriesResponse` |
| Home | `GET /v1/activity` | — | `fetchRecentActivity` | `ActivityEvent[]` |
| Home | `GET /v1/regions/health` | — | `fetchRegionHealth` | `RegionHealth[]` |
| Home | `GET /v1/onboarding` | — | `fetchOnboarding` | `OnboardingStep[]` |
| Users | `GET /v1/users` | `tier` | `fetchUsers` | `UsersResponse` |
| Documents | `GET /v1/documents` | `tier` | `fetchDocuments` | `DocumentsResponse` |
| Pipelines | `GET /v1/pipelines` · `POST /v1/pipelines/:id/promote-to-policy` | `tier` | `fetchPipelines` · `promoteToPolicy` | `PipelinesResponse` |
-7
View File
@@ -2,7 +2,6 @@ import { apiClient } from "@portal/api/http";
import type {
ActivityEvent,
KpiEntry,
OnboardingStep,
RegionHealth,
UsageSeriesResponse,
} from "@portal/mocks/home";
@@ -12,7 +11,6 @@ export type {
ActivityEvent,
ActivityKind,
KpiEntry,
OnboardingStep,
PipelineStage,
PipelineTemplate,
RegionHealth,
@@ -44,8 +42,3 @@ export async function fetchHomeKpis(tier: Tier): Promise<KpiEntry[]> {
export async function fetchRegionHealth(): Promise<RegionHealth[]> {
return apiClient.local.json<RegionHealth[]>("/v1/regions/health");
}
/** GET /v1/onboarding (Free) */
export async function fetchOnboarding(): Promise<OnboardingStep[]> {
return apiClient.local.json<OnboardingStep[]>("/v1/onboarding");
}
@@ -0,0 +1,118 @@
/* ──────────────────────────────────────────────────────────────────────── */
/* Subscribed-tier deployed-Editor hero */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-editor-hero {
border-radius: var(--radius-xl);
border: 1px solid var(--color-border-input);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.portal-editor-hero__row {
display: flex;
align-items: center;
gap: 1.25rem;
padding: 1.25rem 1.5rem;
}
.portal-editor-hero__logo {
width: 3.5rem;
height: 3.5rem;
border-radius: 0.875rem;
overflow: hidden;
flex-shrink: 0;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(0, 0, 0, 0.06);
}
.portal-editor-hero__mark {
width: 100%;
height: 100%;
display: block;
}
.portal-editor-hero__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-editor-hero__title-row {
display: flex;
align-items: center;
gap: 0.5625rem;
flex-wrap: wrap;
}
.portal-editor-hero__name {
font-size: 1.125rem;
font-weight: 700;
color: var(--color-text-1);
margin-right: 0.125rem;
}
.portal-editor-hero__chip {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
padding: 0.3125rem 0.75rem;
border-radius: var(--radius-pill);
border: 1px solid var(--color-border-input);
background: var(--color-bg-code);
color: var(--color-text-2);
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);
}
.portal-editor-hero__chip:hover {
background: var(--color-surface);
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.1);
transform: translateY(-1px);
}
.portal-editor-hero__chip svg {
color: var(--color-text-4);
}
.portal-editor-hero__meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.75rem;
color: var(--color-text-5);
}
.portal-editor-hero__host {
color: var(--color-text-2);
font-family: var(--font-mono);
font-weight: 500;
}
.portal-editor-hero__meta-sep {
color: var(--color-border-input);
}
.portal-editor-hero__action {
flex-shrink: 0;
}
@media (max-width: 48rem) {
.portal-editor-hero__row {
flex-wrap: wrap;
}
.portal-editor-hero__action {
width: 100%;
}
}
/* Attached footer strip (setup checklist). */
.portal-editor-hero__footer {
border-top: 1px solid var(--color-border-light);
}
@@ -0,0 +1,50 @@
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";
const meta: Meta<typeof EditorStatusCard> = {
title: "Portal/Home/EditorStatusCard",
component: EditorStatusCard,
parameters: { layout: "padded" },
globals: { tier: "pro" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof EditorStatusCard>;
/** The deployed-Editor status card on its own. */
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 />,
},
};
/**
* Backend without the editor-deployment endpoint (404): the status row is
* skipped and the card falls back to just the footer (setup checklist). It
* lights up automatically once /v1/editor/deployment is served.
*/
export const DeploymentUnavailable: Story = {
args: {
footer: <SetupChecklist />,
},
parameters: {
msw: {
handlers: [
http.get("/v1/editor/deployment", () =>
HttpResponse.json({ message: "not found" }, { status: 404 }),
),
],
},
},
};
@@ -0,0 +1,194 @@
import type { ReactNode } from "react";
import { Fragment, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Button, Skeleton } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useView } from "@portal/contexts/ViewContext";
import { useAsync } from "@portal/hooks/useAsync";
import {
fetchEditorDeployment,
type EditorDeploymentResponse,
type EditorInstance,
} from "@portal/api/editorDeploy";
import {
ExternalLinkIcon,
UsersIcon,
UserPlusIcon,
} from "@portal/components/icons";
import "@portal/components/EditorStatusCard.css";
/** The Stirling brand mark, drawn at the hero size. Decorative. */
function StirlingMark() {
return (
<svg
className="portal-editor-hero__mark"
viewBox="0 0 256 256"
fill="none"
aria-hidden
>
<rect width="256" height="256" rx="58" fill="#8E3131" />
<path
d="M39.2638 127.834L155.374 32L155.375 121.499L39.2638 217.333L39.2638 127.834Z"
fill="white"
/>
<path
d="M159 124.5L159 88.5L216.728 38.4472L216.728 128.052L100.479 224L100.479 172L159 124.5Z"
fill="white"
fillOpacity="0.6"
/>
</svg>
);
}
/** The instance to headline: the busiest healthy one, else the first. */
function primaryInstance(instances: EditorInstance[]): EditorInstance | null {
if (instances.length === 0) return null;
const healthy = instances.filter((i) => i.status === "healthy");
const pool = healthy.length > 0 ? healthy : instances;
return pool.reduce((best, i) =>
i.activeUsers > best.activeUsers ? i : best,
);
}
interface EditorStatusCardProps {
/**
* Rendered as an attached footer strip inside the card (e.g. the "Finish
* setting up" checklist), matching the free-tier hero's footer seam.
*/
footer?: ReactNode;
/**
* Hide the active-users / invite chips. Used on enterprise, where the
* attached procurement deal hero already owns the invite action.
*/
hideChips?: boolean;
}
/**
* Subscribed/enterprise home hero: a status card for the org's deployed PDF
* Editor. Reads the same `/v1/editor/deployment` data as the Editor admin view
* (host, version, live users, deployment shape) and headlines the busiest
* instance, with a single "Open in browser" action to the workspace URL.
*/
export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) {
const { t } = useTranslation();
const { tier } = useTier();
const { setActiveView } = useView();
const { data, loading } = useAsync<EditorDeploymentResponse>(
() => fetchEditorDeployment(tier),
[tier],
);
const view = useMemo(() => {
if (!data) return null;
const primary = primaryInstance(data.instances);
if (!primary) return null;
const activeUsers = data.instances.reduce((s, i) => s + i.activeUsers, 0);
const targetLabel = t(`portal.home.editor.target.${primary.target}`);
return {
host: primary.host,
activeUsers,
workspaceUrl: data.summary.workspaceUrl,
meta: [
// Skip the deployment label when it just repeats the host (e.g. the
// managed-cloud instance is literally named "Managed Cloud").
primary.host === targetLabel ? null : targetLabel,
primary.region,
`v${primary.version}`,
t("portal.home.editor.updated", { time: primary.lastSeen }),
].filter(Boolean) as string[],
};
}, [data, t]);
const ready = !loading && !!view;
// The editor-deployment endpoint isn't implemented on every backend yet.
// When it's unavailable (finished loading with no data — e.g. a 404), skip
// the status row entirely and fall back to just the footer (the setup
// checklist, which reads supported endpoints). It lights up automatically
// once the backend serves /v1/editor/deployment.
const unavailable = !loading && !view;
if (unavailable) {
return footer ? (
<section className="portal-editor-hero portal-editor-hero--footer-only">
{footer}
</section>
) : null;
}
return (
<section
className="portal-editor-hero"
aria-label={t("portal.home.editor.name")}
>
<div className="portal-editor-hero__row">
<div className="portal-editor-hero__logo">
<StirlingMark />
</div>
<div className="portal-editor-hero__info">
{!ready || !view ? (
<>
<Skeleton width="12rem" height="1.25rem" />
<Skeleton width="22rem" height="0.75rem" />
</>
) : (
<>
<div className="portal-editor-hero__title-row">
<span className="portal-editor-hero__name">
{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>
</>
)}
</div>
<div className="portal-editor-hero__meta">
<span className="portal-editor-hero__host">{view.host}</span>
{view.meta.map((item, i) => (
<Fragment key={i}>
<span className="portal-editor-hero__meta-sep">·</span>
<span>{item}</span>
</Fragment>
))}
</div>
</>
)}
</div>
<div className="portal-editor-hero__action">
<Button
variant="primary"
leftSection={<ExternalLinkIcon size={13} />}
disabled={!ready || !view}
onClick={() => {
if (view)
window.open(view.workspaceUrl, "_blank", "noopener,noreferrer");
}}
>
{t("portal.home.editor.open")}
</Button>
</div>
</div>
{footer && <div className="portal-editor-hero__footer">{footer}</div>}
</section>
);
}
@@ -0,0 +1,30 @@
import { useTranslation } from "react-i18next";
/**
* Time-of-day greeting + today's date, shown above the paid-tier home hero.
* Free tier opens with the welcome banner instead, so this is paid-only.
*/
export function HomeGreeting() {
const { t } = useTranslation();
const now = new Date();
const hour = now.getHours();
const greeting =
hour < 12
? t("portal.home.greeting.morning")
: hour < 18
? t("portal.home.greeting.afternoon")
: t("portal.home.greeting.evening");
const date = now.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
return (
<header className="portal-home__greeting">
<h1 className="portal-home__greeting-title">{greeting}</h1>
<p className="portal-home__greeting-date">{date}</p>
</header>
);
}
@@ -0,0 +1,42 @@
import type { Tier } from "@portal/contexts/TierContext";
import { WelcomeBanner } from "@portal/components/WelcomeBanner";
import { EditorStatusCard } from "@portal/components/EditorStatusCard";
import { SetupChecklist } from "@portal/components/SetupChecklist";
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:
*
* - free → marketing welcome banner
* - subscribed → deployed-Editor status card
* - enterprise → deployed-Editor status card (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.
*/
export function HomeHero({ tier }: { tier: Tier }) {
const procurement = useProcurement();
const dealActive =
procurement.isLinked && procurement.started && !!procurement.data;
const footer = dealActive ? (
<ControlledDealStatusHero controller={procurement} />
) : (
<SetupChecklist />
);
return (
<>
{tier === "free" ? (
<WelcomeBanner footer={footer} />
) : (
<EditorStatusCard footer={footer} hideChips={tier === "enterprise"} />
)}
<ProcurementFlow controller={procurement} />
</>
);
}
@@ -0,0 +1,188 @@
/* ──────────────────────────────────────────────────────────────────────── */
/* "Finish setting up" checklist (welcome-hero footer) */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-setup {
display: flex;
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;
padding: 0;
}
.portal-setup__row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.875rem;
width: 100%;
padding: 0.875rem 1.25rem;
border: none;
border-top: 1px solid var(--color-border-light);
background: transparent;
text-align: left;
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 {
background: var(--color-bg-hover);
}
.portal-setup__row:disabled {
cursor: default;
}
.portal-setup__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
background: var(--color-border-input);
}
.portal-setup__dot.is-done {
background: var(--color-green);
}
.portal-setup__text {
display: flex;
flex-direction: column;
min-width: 0;
}
.portal-setup__text strong {
font-size: 0.84375rem;
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;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 0.875rem 1.25rem;
border-top: 1px solid var(--color-border-light);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--color-blue) 5%, transparent) 0%,
transparent 55%
);
}
.portal-setup__enterprise-copy {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1;
}
.portal-setup__enterprise-tag {
flex-shrink: 0;
padding: 0.1875rem 0.5625rem;
border-radius: var(--radius-md);
font-size: 0.59375rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-blue-dark);
background: var(--color-blue-light);
}
.portal-setup__enterprise-text {
margin: 0;
font-size: 0.8125rem;
line-height: 1.45;
color: var(--color-text-3);
min-width: 0;
}
.portal-setup__enterprise-text strong {
color: var(--color-text-1);
font-weight: 700;
}
@@ -0,0 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { SetupChecklist } from "@portal/components/SetupChecklist";
const meta: Meta<typeof SetupChecklist> = {
title: "Portal/Home/SetupChecklist",
component: SetupChecklist,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div
style={{
maxWidth: "60rem",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-xl)",
overflow: "hidden",
background: "var(--color-surface)",
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SetupChecklist>;
/** Completion + counts derived from the live policies / sources MSW fixtures. */
export const Default: 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([]);
}),
],
},
},
};
/** 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: [] }),
),
],
},
},
};
@@ -0,0 +1,218 @@
import { useMemo, 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 "@portal/components/SetupChecklist.css";
/** Where the Stirling PDF Editor desktop app is downloaded. */
const EDITOR_DOWNLOAD_URL = "https://stirling.com/download";
/* ──────────────────────────────────────────────────────────────────────── */
/* Enterprise upsell rung */
/* ──────────────────────────────────────────────────────────────────────── */
/**
* Enterprise on-ramp rung. The CTA differs by tier: free orgs start a guided
* trial, subscribed (paying) orgs jump straight to a quote — both land in the
* procurement flow.
*/
function EnterpriseRung({ paying }: { paying: boolean }) {
const { t } = useTranslation();
const { setActiveView } = useView();
return (
<div className="portal-setup__enterprise">
<div className="portal-setup__enterprise-copy">
<span className="portal-setup__enterprise-tag">
{t("portal.home.onboarding.enterprise.tag")}
</span>
<p className="portal-setup__enterprise-text">
<strong>{t("portal.home.onboarding.enterprise.lead")}</strong>{" "}
{t("portal.home.onboarding.enterprise.body")}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setActiveView("procurement")}
rightSection={<span aria-hidden></span>}
>
{t(
paying
? "portal.home.onboarding.enterprise.ctaQuote"
: "portal.home.onboarding.enterprise.cta",
)}
</Button>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* "Finish setting up" checklist */
/* ──────────────────────────────────────────────────────────────────────── */
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;
}
/**
* 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.
*/
export function SetupChecklist() {
const { t } = useTranslation();
const { tier } = useTier();
const { setActiveView } = useView();
const [dismissed, setDismissed] = 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);
}
}
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>
<button
type="button"
className="portal-setup__dismiss"
onClick={() => setDismissed(true)}
title={t("portal.home.onboarding.dismiss")}
>
{t("portal.home.onboarding.progress", {
done: doneCount,
total: steps.length,
})}
<CloseIcon size={13} />
</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>
</>
)}
<EnterpriseRung paying={tier !== "free"} />
</div>
);
}
@@ -0,0 +1,461 @@
/* ──────────────────────────────────────────────────────────────────────── */
/* Free-tier welcome hero */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-welcome {
border-radius: var(--radius-xl);
border: 1px solid var(--color-blue-border);
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 {
display: flex;
gap: 0.75rem;
align-items: center;
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);
}
/* ── 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;
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 {
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 {
display: flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border-radius: 0.3125rem;
}
.portal-welcome__rail-icon:nth-child(2) {
background: rgba(96, 165, 250, 0.18);
color: rgba(147, 197, 253, 0.95);
}
/* ── 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;
background: #ffffff;
color: #0f172a;
box-shadow: 0 0.25rem 1.125rem rgba(0, 0, 0, 0.22);
}
.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) ── */
.portal-welcome__footer {
position: relative;
z-index: 2;
border-top: 1px solid var(--color-border-light);
background: var(--color-surface);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { WelcomeBanner } from "@portal/components/WelcomeBanner";
import { SetupChecklist } from "@portal/components/SetupChecklist";
const meta: Meta<typeof WelcomeBanner> = {
title: "Portal/Home/WelcomeBanner",
component: WelcomeBanner,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof WelcomeBanner>;
/** The hero on its own, no attached footer. */
export const Default: Story = {};
/** The hero as it renders on the free-tier home: the "Finish setting up"
* checklist attached as the footer strip. */
export const WithSetupChecklist: Story = {
args: {
footer: <SetupChecklist />,
},
};
@@ -0,0 +1,183 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import { useView } from "@portal/contexts/ViewContext";
import {
DownloadIcon,
UsersIcon,
EditorIcon,
SearchIcon,
SourcesIcon,
PoliciesIcon,
SparklesIcon,
ComponentsIcon,
DocumentsIcon,
SettingsIcon,
} from "@portal/components/icons";
import "@portal/components/WelcomeBanner.css";
/* ──────────────────────────────────────────────────────────────────────── */
/* Editor-card visual */
/* */
/* 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) */
/* ──────────────────────────────────────────────────────────────────────── */
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.
*/
footer?: ReactNode;
}
export function WelcomeBanner({ footer }: WelcomeBannerProps) {
const { t } = useTranslation();
const { setActiveView } = useView();
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")}
</span>
<h1 className="portal-welcome__title">
{t("portal.welcome.title")}{" "}
<span className="portal-welcome__title-accent">
{t("portal.welcome.titleAccent")}
</span>
</h1>
<p className="portal-welcome__sub">{t("portal.welcome.subtitle")}</p>
<div className="portal-welcome__cta">
<Button
variant="primary"
leftSection={<DownloadIcon size={15} />}
onClick={() => setActiveView("editor")}
>
{t("portal.welcome.installEditor")}
</Button>
<Button
variant="secondary"
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>
</div>
{footer && <div className="portal-welcome__footer">{footer}</div>}
</section>
);
}
@@ -1,234 +0,0 @@
.portal-carousel {
position: relative;
overflow: hidden;
padding: 2rem;
border-radius: var(--radius-xl);
background: var(--grad-banner);
border: 1px solid var(--color-border);
isolation: isolate;
}
.portal-carousel__inner {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
gap: 2rem;
align-items: center;
animation: fadeInUp var(--motion-enter) both;
}
@media (max-width: 50rem) {
.portal-carousel__inner {
grid-template-columns: 1fr;
}
}
.portal-carousel__text {
min-width: 0;
}
.portal-carousel__eyebrow {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-blue);
margin-bottom: 0.5rem;
}
.portal-carousel__title {
margin: 0 0 0.75rem;
font-size: 1.75rem;
font-weight: 700;
line-height: 1.15;
color: var(--color-text-1);
}
.portal-carousel__sub {
margin: 0 0 1.25rem;
font-size: 0.9375rem;
line-height: 1.55;
color: var(--color-text-3);
max-width: 36rem;
}
.portal-carousel__cta {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Ornament container */
.portal-carousel__ornament {
display: flex;
align-items: center;
justify-content: center;
}
/* Doc card ornament (slide 0) */
.portal-carousel__doc {
width: 100%;
padding: 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.portal-carousel__doc-title {
margin-top: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-carousel__doc-sub {
margin-top: 0.125rem;
font-size: 0.75rem;
color: var(--color-text-4);
}
.portal-carousel__doc-meta {
margin-top: 0.75rem;
font-size: 0.75rem;
color: var(--color-text-4);
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
/* Code ornament (slide 1) */
.portal-carousel__code {
width: 100%;
margin: 0;
padding: 0.875rem 1rem;
background: var(--code-bg);
color: var(--code-text);
border: 1px solid var(--code-border);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
box-shadow: var(--shadow-lg);
}
.portal-carousel__code-method {
color: var(--code-fn);
font-weight: 600;
}
.portal-carousel__code-path {
color: var(--code-keyword);
}
.portal-carousel__code-key {
color: var(--code-property);
}
.portal-carousel__code-string {
color: var(--code-string);
}
/* Eval ornament (slide 2) */
.portal-carousel__eval {
width: 100%;
padding: 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.portal-carousel__eval-head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.portal-carousel__eval-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-carousel__eval-score {
font-size: 1.125rem;
font-weight: 700;
color: var(--color-green);
}
.portal-carousel__eval-sub {
font-size: 0.75rem;
color: var(--color-text-4);
margin-top: 0.125rem;
}
.portal-carousel__eval-bar {
margin-top: 0.75rem;
height: 0.375rem;
background: var(--color-bg-muted);
border-radius: var(--radius-pill);
overflow: hidden;
}
.portal-carousel__eval-fill {
height: 100%;
background: var(--grad-green-btn);
}
.portal-carousel__eval-meta {
margin-top: 0.625rem;
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
color: var(--color-text-4);
}
/* Decoration blobs */
.portal-carousel__decor {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.portal-carousel__blob {
position: absolute;
border-radius: 50%;
filter: blur(2.5rem);
}
.portal-carousel__blob--1 {
top: -3rem;
right: -2rem;
width: 16rem;
height: 16rem;
background: radial-gradient(circle, var(--color-purple), transparent 70%);
opacity: 0.4;
}
.portal-carousel__blob--2 {
bottom: -4rem;
right: 8rem;
width: 12rem;
height: 12rem;
background: radial-gradient(circle, var(--color-blue), transparent 70%);
opacity: 0.3;
}
/* Pagination dots */
.portal-carousel__dots {
position: absolute;
bottom: 0.875rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.375rem;
z-index: 2;
}
.portal-carousel__dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: var(--color-text-5);
opacity: 0.4;
transition:
opacity var(--motion-fast),
background var(--motion-fast),
width var(--motion-fast);
}
.portal-carousel__dot:hover {
opacity: 0.7;
}
.portal-carousel__dot.is-active {
width: 1rem;
border-radius: var(--radius-pill);
background: var(--color-blue);
opacity: 1;
}
@@ -1,20 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { WelcomeCarousel } from "@portal/components/WelcomeCarousel";
const meta: Meta<typeof WelcomeCarousel> = {
title: "Portal/Home/WelcomeCarousel",
component: WelcomeCarousel,
args: { onTryOp: () => console.log("try op") },
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "64rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof WelcomeCarousel>;
export const AutoRotating: Story = {};
@@ -1,232 +0,0 @@
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button, StatusBadge } from "@app/ui";
import { useView, type ViewId } from "@portal/contexts/ViewContext";
import "@portal/components/WelcomeCarousel.css";
type SlideAction =
| { labelKey: string; target: ViewId }
| { labelKey: string; action: "try-op" };
interface Slide {
id: string;
durationMs: number;
primary: SlideAction;
secondary: SlideAction;
ornament: ReactNode;
}
function EditorOrnament() {
const { t } = useTranslation();
return (
<div className="portal-carousel__doc">
<StatusBadge tone="danger" size="sm" pulse>
{t("portal.welcome.ornament.editor.critical")}
</StatusBadge>
<div className="portal-carousel__doc-title">
Vulnerability Assessment Report
</div>
<div className="portal-carousel__doc-sub">CVE-2026-1847 · 12 pages</div>
<div className="portal-carousel__doc-meta">
<span>{t("portal.welcome.ornament.editor.signed")}</span>
<span>·</span>
<span>{t("portal.welcome.ornament.editor.ocrClean")}</span>
<span>·</span>
<span>{t("portal.welcome.ornament.editor.schemaMatch")}</span>
</div>
</div>
);
}
function PlatformOrnament() {
return (
<pre className="portal-carousel__code">
<span className="portal-carousel__code-method">POST</span>{" "}
<span className="portal-carousel__code-path">/v1/secure</span>
{"\n"}
<span className="portal-carousel__code-key">Authorization</span>:{" "}
<span className="portal-carousel__code-string">Bearer sk_live_a3f8</span>
{"\n"}
<span className="portal-carousel__code-key">file</span>:{" "}
<span className="portal-carousel__code-string">
federal_CUI_contract.pdf
</span>
{"\n"}
<span className="portal-carousel__code-key">pipeline</span>:{" "}
<span className="portal-carousel__code-string">cui-redact-v2</span>
</pre>
);
}
function AgentOrnament() {
return (
<div className="portal-carousel__eval">
<div className="portal-carousel__eval-head">
<span className="portal-carousel__eval-title">KYC Processor</span>
<span className="portal-carousel__eval-score">94%</span>
</div>
<div className="portal-carousel__eval-sub">
Eval pass rate · 28 test cases
</div>
<div className="portal-carousel__eval-bar">
<div
className="portal-carousel__eval-fill"
style={{ width: "94%" }}
aria-hidden
/>
</div>
<div className="portal-carousel__eval-meta">
<span>26 passed</span>
<span>2 review</span>
<span>MCP · Claude</span>
</div>
</div>
);
}
const SLIDES: Slide[] = [
{
id: "editor",
durationMs: 12000,
primary: {
labelKey: "portal.welcome.slides.editor.primary",
target: "editor",
},
secondary: {
labelKey: "portal.welcome.slides.editor.secondary",
target: "editor",
},
ornament: <EditorOrnament />,
},
{
id: "platform",
durationMs: 8000,
primary: {
labelKey: "portal.welcome.slides.platform.primary",
action: "try-op",
},
secondary: {
labelKey: "portal.welcome.slides.platform.secondary",
target: "infrastructure",
},
ornament: <PlatformOrnament />,
},
{
id: "agents",
durationMs: 8000,
primary: {
labelKey: "portal.welcome.slides.agents.primary",
target: "sources",
},
secondary: {
labelKey: "portal.welcome.slides.agents.secondary",
target: "docs",
},
ornament: <AgentOrnament />,
},
];
interface WelcomeCarouselProps {
/** Called when a slide CTA wants to invoke the single-op runner. */
onTryOp: () => void;
}
export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {
const { t } = useTranslation();
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const { setActiveView } = useView();
function runAction(act: SlideAction) {
if ("action" in act && act.action === "try-op") {
onTryOp();
} else if ("target" in act) {
setActiveView(act.target);
}
}
useEffect(() => {
if (paused) return;
const duration = SLIDES[index].durationMs;
const t = window.setTimeout(() => {
setIndex((i) => (i + 1) % SLIDES.length);
}, duration);
return () => window.clearTimeout(t);
}, [index, paused]);
const slide = SLIDES[index];
return (
<section
className="portal-carousel"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={(e) => {
// onBlur bubbles from children; only unpause when focus actually
// leaves the carousel, not when tabbing between the dot buttons.
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setPaused(false);
}
}}
aria-label={t("portal.welcome.ariaLabel")}
aria-roledescription="carousel"
>
<div className="portal-carousel__inner" key={slide.id}>
<div className="portal-carousel__text">
<div className="portal-carousel__eyebrow">
{t(`portal.welcome.slides.${slide.id}.eyebrow`)}
</div>
<h1 className="portal-carousel__title">
{t(`portal.welcome.slides.${slide.id}.title`)}
</h1>
<p className="portal-carousel__sub">
{t(`portal.welcome.slides.${slide.id}.sub`)}
</p>
<div className="portal-carousel__cta">
<Button
onClick={() => runAction(slide.primary)}
rightSection={<span aria-hidden></span>}
>
{t(slide.primary.labelKey)}
</Button>
<Button
variant="secondary"
onClick={() => runAction(slide.secondary)}
>
{t(slide.secondary.labelKey)}
</Button>
</div>
</div>
<div className="portal-carousel__ornament">{slide.ornament}</div>
</div>
<div className="portal-carousel__decor" aria-hidden>
<div className="portal-carousel__blob portal-carousel__blob--1" />
<div className="portal-carousel__blob portal-carousel__blob--2" />
</div>
<div
className="portal-carousel__dots"
role="group"
aria-label={t("portal.welcome.pagination")}
>
{SLIDES.map((s, i) => (
<Button
key={s.id}
variant="tertiary"
aria-current={i === index ? "true" : undefined}
aria-label={t("portal.welcome.slideLabel", {
number: i + 1,
title: t(`portal.welcome.slides.${s.id}.title`),
})}
className={
"portal-carousel__dot" + (i === index ? " is-active" : "")
}
onClick={() => setIndex(i)}
/>
))}
</div>
</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>
</>
@@ -254,6 +254,16 @@ export function ProcurementIcon(props: IconProps) {
);
}
export function DownloadIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</Svg>
);
}
export function LinkIcon(props: IconProps) {
return (
<Svg {...props}>
@@ -262,3 +272,24 @@ export function LinkIcon(props: IconProps) {
</Svg>
);
}
export function UserPlusIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="22" y1="11" x2="16" y2="11" />
</Svg>
);
}
export function ExternalLinkIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</Svg>
);
}
@@ -0,0 +1,79 @@
import { useTranslation } from "react-i18next";
import { Button, Card } from "@app/ui";
import { useView } from "@portal/contexts/ViewContext";
import { DealStatusHero } from "@portal/components/procurement/DealStatusHero";
import type { ProcurementController } from "@portal/components/procurement/useProcurement";
/**
* The deal-status hero, wired to a shared ProcurementController. Rendered both
* standalone (the /procurement route) and as the Home hero card's footer once a
* deal is underway. Assumes an active deal (controller.data present).
*/
export function ControlledDealStatusHero({
controller,
}: {
controller: ProcurementController;
}) {
const { setActiveView } = useView();
if (!controller.data) return null;
return (
<DealStatusHero
snapshot={controller.data}
busy={controller.busy}
onExpand={() => controller.setOpen(true)}
onKeyDocs={() => controller.setExtra("docs")}
onInvite={() => setActiveView("users")}
onSchedule={() => controller.setExtra("schedule")}
onManageTrial={() => controller.setExtra("trial")}
onNavigate={setActiveView}
/>
);
}
/**
* Enterprise on-ramp shown when no deal exists yet. Only used on the dedicated
* /procurement route — on Home the setup checklist's Enterprise rung owns the
* on-ramp, so this doesn't render there.
*/
export function ProcurementUpsell({
controller,
}: {
controller: ProcurementController;
}) {
const { t } = useTranslation();
return (
<Card className="portal-proc__upsell">
<div className="portal-proc__upsell-text">
<span className="portal-proc__upsell-badge">
{t("portal.procurement.upsell.homeBadge")}
</span>
<p className="portal-proc__upsell-copy">
<strong>{t("portal.procurement.upsell.homeHeadline")} </strong>
{t("portal.procurement.upsell.homeBody")}
</p>
</div>
<Button
variant="secondary"
accent="default"
loading={controller.busy}
disabled={!controller.isLinked}
onClick={controller.onStartTrial}
>
{t("portal.procurement.upsell.homeCta")}
</Button>
</Card>
);
}
/** Deal-status hero when a deal is underway, otherwise the enterprise on-ramp. */
export function ProcurementBanner({
controller,
}: {
controller: ProcurementController;
}) {
return controller.isLinked && controller.started && controller.data ? (
<ControlledDealStatusHero controller={controller} />
) : (
<ProcurementUpsell controller={controller} />
);
}
@@ -0,0 +1,181 @@
import { useTranslation } from "react-i18next";
import { Banner, Button, EmptyState, Skeleton } from "@app/ui";
import { useUI } from "@portal/contexts/UIContext";
import { JOURNEY } from "@portal/api/procurement";
import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement";
import {
KeyDocumentsModal,
ScheduleCallModal,
TrialManageModal,
} from "@portal/components/procurement/ProcurementExtras";
import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
import {
LiveStageCard,
PaymentStageCard,
QuoteMilestoneCard,
} from "@portal/components/procurement/ProcurementStages";
import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
import { StageStepper } from "@portal/components/procurement/StageStepper";
import type { ProcurementController } from "@portal/components/procurement/useProcurement";
/**
* The procurement takeover flow: the full-screen journey modal (quote builder →
* milestone → agreement → payment → live) plus the key-documents, schedule-call,
* and trial-management modals. Driven entirely by a shared ProcurementController
* so it can sit next to a deal-status hero rendered elsewhere (e.g. inside the
* tier hero card on Home).
*/
export function ProcurementFlow({
controller,
}: {
controller: ProcurementController;
}) {
const { t } = useTranslation();
const { openLinkModal } = useUI();
const {
isLinked,
loading,
data,
started,
stage,
latest,
isIssued,
isDraft,
busy,
downloading,
error,
setError,
open,
setOpen,
editing,
setEditing,
extra,
setExtra,
invoicePdf,
onExtendTrial,
onReset,
onGenerate,
onAcceptQuote,
onAgree,
onGoLive,
onDownloadPdf,
} = controller;
return (
<>
<ProcurementModal
open={open}
onClose={() => setOpen(false)}
title={t("portal.procurement.title")}
subtitle={t("portal.procurement.subtitle")}
>
{error && (
<Banner
tone="danger"
title={t("portal.procurement.error.title")}
onDismiss={() => setError(null)}
>
{error}
</Banner>
)}
{!isLinked && (
<EmptyState
eyebrow={t("portal.procurement.link.eyebrow")}
title={t("portal.procurement.link.title")}
description={t("portal.procurement.link.description")}
actions={
<Button
variant="primary"
accent="premium"
onClick={() => openLinkModal()}
>
{t("portal.procurement.link.cta")}
</Button>
}
/>
)}
{isLinked && (loading || !started) && <Skeleton height="10rem" />}
{isLinked && started && (
<>
<div className="portal-proc__modal-stepper">
<StageStepper journey={JOURNEY} currentStage={stage!} />
</div>
{(editing ||
(isDraft && (stage === "trial" || stage === "quote"))) && (
<QuoteBuilder
deployment="cloud"
initial={latest?.config}
onGenerate={onGenerate}
/>
)}
{!editing && isIssued && stage === "quote" && latest && (
<QuoteMilestoneCard
quote={latest}
busy={busy}
downloading={downloading}
onAccept={onAcceptQuote}
onDownload={onDownloadPdf}
onEdit={() => setEditing(true)}
/>
)}
{!editing && stage === "security" && latest && (
<ProcurementAgreement
quote={latest}
busy={busy}
onAgree={onAgree}
/>
)}
{!editing && stage === "procurement" && latest && (
<PaymentStageCard
invoiceUrl={latest.invoiceUrl}
invoicePdf={invoicePdf}
busy={busy}
onSimulate={onGoLive}
/>
)}
{!editing && stage === "active" && <LiveStageCard />}
<div className="portal-proc__reset">
<button type="button" onClick={onReset} disabled={busy}>
{t("portal.procurement.reset")}
</button>
</div>
</>
)}
</ProcurementModal>
<KeyDocumentsModal
open={extra === "docs"}
onClose={() => setExtra(null)}
/>
<ScheduleCallModal
open={extra === "schedule"}
onClose={() => setExtra(null)}
/>
{data && (
<TrialManageModal
open={extra === "trial"}
onClose={() => setExtra(null)}
snapshot={data}
busy={busy}
onExtend={async () => {
await onExtendTrial();
setExtra(null);
}}
onCancel={async () => {
await onReset();
setExtra(null);
}}
/>
)}
</>
);
}
@@ -1,296 +1,22 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, Card, EmptyState, Skeleton } from "@app/ui";
import { usePortalLinked } from "@portal/contexts/usePortalLinked";
import { useUI } from "@portal/contexts/UIContext";
import { useView } from "@portal/contexts/ViewContext";
import { useAsync } from "@portal/hooks/useAsync";
import {
acceptQuote,
extendTrial,
fetchQuotePdf,
fetchSnapshot,
goLive,
issueQuote,
JOURNEY,
resetProcurement,
startAgreement,
startTrial,
type ProcurementSnapshot,
type QuoteResult,
} from "@portal/api/procurement";
import { DealStatusHero } from "@portal/components/procurement/DealStatusHero";
import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement";
import {
KeyDocumentsModal,
ScheduleCallModal,
TrialManageModal,
} from "@portal/components/procurement/ProcurementExtras";
import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
import {
LiveStageCard,
PaymentStageCard,
QuoteMilestoneCard,
} from "@portal/components/procurement/ProcurementStages";
import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
import { StageStepper } from "@portal/components/procurement/StageStepper";
import { ProcurementBanner } from "@portal/components/procurement/ProcurementBanner";
import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow";
import { useProcurement } from "@portal/components/procurement/useProcurement";
import "@portal/views/Procurement.css";
/**
* The procurement experience on Home: a compact deal-status hero once a trial is running (or the
* enterprise upsell on-ramp before it), both expanding into the full-screen takeover modal that
* holds the journey — build + issue a quote (a Stripe Quote with a real PDF, the milestone the buyer
* can share and return to), review + agree to the enterprise agreement, then accept into a committed
* subscription. Starting a trial is a single click (no "start a trial" prompt); the deadline + next
* steps then show on the hero. Rendered on Home and at /procurement (autoOpen). Gated on a link.
* The standalone procurement experience: a deal-status hero (or enterprise
* on-ramp when no deal exists) above the full-screen takeover flow that holds
* the journey — build + issue a quote, review + agree to the enterprise
* agreement, then accept into a committed subscription. Rendered at
* /procurement (autoOpen). On Home the deal-status hero instead attaches to the
* tier hero card's footer (see HomeHero) so this component isn't used there.
*/
export function ProcurementHome({ autoOpen = false }: { autoOpen?: boolean }) {
const { t } = useTranslation();
const isLinked = usePortalLinked();
const { openLinkModal } = useUI();
const { setActiveView } = useView();
const state = useAsync<ProcurementSnapshot | null>(
() => (isLinked ? fetchSnapshot() : Promise.resolve(null)),
[isLinked],
);
const [snap, setSnap] = useState<ProcurementSnapshot | null>(null);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState(false);
const [downloading, setDownloading] = useState(false);
const [invoicePdf, setInvoicePdf] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [extra, setExtra] = useState<null | "docs" | "schedule" | "trial">(
null,
);
const data = snap ?? (state.loading ? null : state.data);
const started = data?.dealId != null;
const stage = data?.stage;
const latest = data?.latestQuote ?? null;
const isIssued = latest?.status === "sent" || latest?.status === "open";
// No live quote to act on (none yet, still a draft, or expired/canceled) → the buyer (re)builds.
const isDraft =
!latest ||
["draft", "expired", "canceled", "cancelled"].includes(latest.status);
async function run(fn: () => Promise<unknown>) {
setBusy(true);
setError(null);
try {
await fn();
setSnap(await fetchSnapshot());
} catch (e) {
console.error("[procurement] action failed", e);
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
const onStartTrial = () => run(startTrial);
const onExtendTrial = () => run(extendTrial);
const onReset = () =>
run(async () => {
await resetProcurement();
setEditing(false);
setInvoicePdf(null);
});
const onGenerate = (draft: QuoteResult) =>
run(async () => {
await issueQuote(draft.quoteId);
setEditing(false);
});
// Milestone → agreement (security) stage; then agreeing accepts into a subscription.
const onAcceptQuote = () => run(startAgreement);
const onAgree = () =>
run(async () => {
if (!latest) return;
const res = await acceptQuote(latest.quoteId);
setInvoicePdf(res.invoicePdf);
});
async function onDownloadPdf() {
if (!latest) return;
setDownloading(true);
try {
const blob = await fetchQuotePdf(latest.quoteId);
// A same-gesture <a download> click is reliable; window.open after an await is often
// popup-blocked (which is what made this take "a few goes").
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${latest.quoteNumber || "quote"}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (e) {
console.error("[procurement] quote PDF download failed", e);
setError(t("portal.procurement.milestone.downloadError"));
} finally {
setDownloading(false);
}
}
// A deep link (/procurement) opens the flow when a deal is already underway; if there's no deal
// yet it must NOT silently start a trial — leave the modal closed so the Start-trial CTA shows.
useEffect(() => {
if (autoOpen && started) setOpen(true);
}, [autoOpen, started]);
const banner =
isLinked && started && data ? (
<DealStatusHero
snapshot={data}
busy={busy}
onExpand={() => setOpen(true)}
onKeyDocs={() => setExtra("docs")}
onInvite={() => setActiveView("users")}
onSchedule={() => setExtra("schedule")}
onManageTrial={() => setExtra("trial")}
onNavigate={setActiveView}
/>
) : (
<Card className="portal-proc__upsell">
<div className="portal-proc__upsell-text">
<span className="portal-proc__upsell-badge">
{t("portal.procurement.upsell.homeBadge")}
</span>
<p className="portal-proc__upsell-copy">
<strong>{t("portal.procurement.upsell.homeHeadline")} </strong>
{t("portal.procurement.upsell.homeBody")}
</p>
</div>
<Button
variant="secondary"
accent="default"
loading={busy}
disabled={!isLinked}
onClick={onStartTrial}
>
{t("portal.procurement.upsell.homeCta")}
</Button>
</Card>
);
const controller = useProcurement(autoOpen);
return (
<>
{banner}
<ProcurementModal
open={open}
onClose={() => setOpen(false)}
title={t("portal.procurement.title")}
subtitle={t("portal.procurement.subtitle")}
>
{error && (
<Banner
tone="danger"
title={t("portal.procurement.error.title")}
onDismiss={() => setError(null)}
>
{error}
</Banner>
)}
{!isLinked && (
<EmptyState
eyebrow={t("portal.procurement.link.eyebrow")}
title={t("portal.procurement.link.title")}
description={t("portal.procurement.link.description")}
actions={
<Button
variant="primary"
accent="premium"
onClick={() => openLinkModal()}
>
{t("portal.procurement.link.cta")}
</Button>
}
/>
)}
{isLinked && (state.loading || !started) && <Skeleton height="10rem" />}
{isLinked && started && (
<>
<div className="portal-proc__modal-stepper">
<StageStepper journey={JOURNEY} currentStage={stage!} />
</div>
{(editing ||
(isDraft && (stage === "trial" || stage === "quote"))) && (
<QuoteBuilder
deployment="cloud"
initial={latest?.config}
onGenerate={onGenerate}
/>
)}
{!editing && isIssued && stage === "quote" && latest && (
<QuoteMilestoneCard
quote={latest}
busy={busy}
downloading={downloading}
onAccept={onAcceptQuote}
onDownload={onDownloadPdf}
onEdit={() => setEditing(true)}
/>
)}
{!editing && stage === "security" && latest && (
<ProcurementAgreement
quote={latest}
busy={busy}
onAgree={onAgree}
/>
)}
{!editing && stage === "procurement" && latest && (
<PaymentStageCard
invoiceUrl={latest.invoiceUrl}
invoicePdf={invoicePdf}
busy={busy}
onSimulate={() => run(goLive)}
/>
)}
{!editing && stage === "active" && <LiveStageCard />}
<div className="portal-proc__reset">
<button type="button" onClick={onReset} disabled={busy}>
{t("portal.procurement.reset")}
</button>
</div>
</>
)}
</ProcurementModal>
<KeyDocumentsModal
open={extra === "docs"}
onClose={() => setExtra(null)}
/>
<ScheduleCallModal
open={extra === "schedule"}
onClose={() => setExtra(null)}
/>
{data && (
<TrialManageModal
open={extra === "trial"}
onClose={() => setExtra(null)}
snapshot={data}
busy={busy}
onExtend={async () => {
await onExtendTrial();
setExtra(null);
}}
onCancel={async () => {
await onReset();
setExtra(null);
}}
/>
)}
<ProcurementBanner controller={controller} />
<ProcurementFlow controller={controller} />
</>
);
}
@@ -0,0 +1,179 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { usePortalLinked } from "@portal/contexts/usePortalLinked";
import { useAsync } from "@portal/hooks/useAsync";
import {
acceptQuote,
extendTrial,
fetchQuotePdf,
fetchSnapshot,
goLive,
issueQuote,
resetProcurement,
startAgreement,
startTrial,
type ProcurementSnapshot,
type QuoteResult,
} from "@portal/api/procurement";
export type ProcurementExtra = null | "docs" | "schedule" | "trial";
/**
* Owns the procurement deal state and actions shared by the Home hero footer
* (deal-status hero) and the takeover flow modals. Extracted from
* ProcurementHome so the deal-status hero can render inside the tier hero card
* while the flow modals live alongside it. Gated on an account link.
*/
export interface ProcurementController {
isLinked: boolean;
loading: boolean;
data: ProcurementSnapshot | null;
started: boolean;
stage: ProcurementSnapshot["stage"] | undefined;
latest: QuoteResult | null;
isIssued: boolean;
isDraft: boolean;
busy: boolean;
downloading: boolean;
error: string | null;
setError: (e: string | null) => void;
open: boolean;
setOpen: (b: boolean) => void;
editing: boolean;
setEditing: (b: boolean) => void;
extra: ProcurementExtra;
setExtra: (e: ProcurementExtra) => void;
invoicePdf: string | null;
onStartTrial: () => void;
onExtendTrial: () => void;
onReset: () => void;
onGenerate: (draft: QuoteResult) => void;
onAcceptQuote: () => void;
onAgree: () => void;
onGoLive: () => void;
onDownloadPdf: () => Promise<void>;
}
export function useProcurement(autoOpen = false): ProcurementController {
const { t } = useTranslation();
const isLinked = usePortalLinked();
const state = useAsync<ProcurementSnapshot | null>(
() => (isLinked ? fetchSnapshot() : Promise.resolve(null)),
[isLinked],
);
const [snap, setSnap] = useState<ProcurementSnapshot | null>(null);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState(false);
const [downloading, setDownloading] = useState(false);
const [invoicePdf, setInvoicePdf] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [extra, setExtra] = useState<ProcurementExtra>(null);
const data = snap ?? (state.loading ? null : state.data);
const started = data?.dealId != null;
const stage = data?.stage;
const latest = data?.latestQuote ?? null;
const isIssued = latest?.status === "sent" || latest?.status === "open";
// No live quote to act on (none yet, still a draft, or expired/canceled) → the buyer (re)builds.
const isDraft =
!latest ||
["draft", "expired", "canceled", "cancelled"].includes(latest.status);
async function run(fn: () => Promise<unknown>) {
setBusy(true);
setError(null);
try {
await fn();
setSnap(await fetchSnapshot());
} catch (e) {
console.error("[procurement] action failed", e);
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
const onStartTrial = () => run(startTrial);
const onExtendTrial = () => run(extendTrial);
const onReset = () =>
run(async () => {
await resetProcurement();
setEditing(false);
setInvoicePdf(null);
});
const onGenerate = (draft: QuoteResult) =>
run(async () => {
await issueQuote(draft.quoteId);
setEditing(false);
});
// Milestone → agreement (security) stage; then agreeing accepts into a subscription.
const onAcceptQuote = () => run(startAgreement);
const onAgree = () =>
run(async () => {
if (!latest) return;
const res = await acceptQuote(latest.quoteId);
setInvoicePdf(res.invoicePdf);
});
const onGoLive = () => run(goLive);
async function onDownloadPdf() {
if (!latest) return;
setDownloading(true);
try {
const blob = await fetchQuotePdf(latest.quoteId);
// A same-gesture <a download> click is reliable; window.open after an await is often
// popup-blocked (which is what made this take "a few goes").
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${latest.quoteNumber || "quote"}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (e) {
console.error("[procurement] quote PDF download failed", e);
setError(t("portal.procurement.milestone.downloadError"));
} finally {
setDownloading(false);
}
}
// A deep link (/procurement) opens the flow when a deal is already underway; if there's no deal
// yet it must NOT silently start a trial — leave the modal closed so the Start-trial CTA shows.
useEffect(() => {
if (autoOpen && started) setOpen(true);
}, [autoOpen, started]);
return {
isLinked,
loading: state.loading,
data,
started,
stage,
latest,
isIssued,
isDraft,
busy,
downloading,
error,
setError,
open,
setOpen,
editing,
setEditing,
extra,
setExtra,
invoicePdf,
onStartTrial,
onExtendTrial,
onReset,
onGenerate,
onAcceptQuote,
onAgree,
onGoLive,
onDownloadPdf,
};
}
@@ -108,6 +108,8 @@ export interface DeploymentSummary {
serviceToken: { masked: string; lastRotated: string };
/** Air-gapped activation is enterprise-only; gate the card on this flag. */
offlineActivationAvailable: boolean;
/** Where users launch the Editor — the org workspace URL (Open in browser). */
workspaceUrl: string;
}
export interface EditorDeploymentResponse {
@@ -377,6 +379,7 @@ function summaryFor(
lastRotated: tier === "enterprise" ? "11 days ago" : "34 days ago",
},
offlineActivationAvailable: tier === "enterprise",
workspaceUrl: "https://app.stirlingpdf.com/o/acme/editor",
};
}
@@ -5,7 +5,6 @@ import {
buildUsageSeriesResponse,
enterpriseKpisFor,
FREE_KPIS,
FREE_ONBOARDING,
proKpisFor,
RECENT_ACTIVITY,
REGION_HEALTH,
@@ -41,9 +40,4 @@ export const homeHandlers = [
await delay(120);
return HttpResponse.json(REGION_HEALTH);
}),
http.get("/v1/onboarding", async () => {
await delay(120);
return HttpResponse.json(FREE_ONBOARDING);
}),
];
+3 -39
View File
@@ -280,42 +280,6 @@ export const PIPELINE_STAGES: PipelineStage[] = [
{ key: "store", label: "Store", detail: "Emit JSON, persist, notify" },
];
export interface OnboardingStep {
id: string;
title: string;
blurb: string;
done: boolean;
/** What to render in the per-step CTA slot. */
cta?: { kind: "try-op" } | { kind: "navigate"; target: string };
}
export const FREE_ONBOARDING: OnboardingStep[] = [
{
id: "first-op",
title: "Run your first operation",
blurb: "Try extract, redact, or OCR on a sample document.",
done: true,
cta: { kind: "try-op" },
},
{
id: "connect-source",
title: "Connect a source",
blurb: "Attach an S3 bucket, webhook, or email inbox.",
done: false,
cta: { kind: "navigate", target: "sources" },
},
{
id: "build-pipeline",
title: "Build a pipeline",
blurb: "Compose ops into a repeatable workflow.",
done: false,
cta: { kind: "navigate", target: "pipelines" },
},
{
id: "wire-agent",
title: "Wire an agent",
blurb: "Expose Stirling via MCP or REST tool definitions.",
done: false,
cta: { kind: "navigate", target: "sources" },
},
];
// The free/subscribed "Finish setting up" checklist is no longer fixture-driven:
// SetupChecklist builds its steps client-side and derives completion from the
// live policies + sources APIs. See components/SetupChecklist.tsx.
+17 -84
View File
@@ -7,6 +7,23 @@
margin: 0 auto;
}
/* Paid-tier greeting header */
.portal-home__greeting {
margin-bottom: -0.25rem;
}
.portal-home__greeting-title {
margin: 0 0 0.25rem;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text-2);
}
.portal-home__greeting-date {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-4);
}
/* Product card grid */
.portal-home__product-grid {
display: grid;
@@ -138,90 +155,6 @@
color: var(--color-blue);
}
/* Free-tier onboarding card */
.portal-home__onboard-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.875rem;
}
.portal-home__onboard-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-home__onboard-sub {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-4);
}
.portal-home__onboard-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-home__onboard-step {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.625rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.portal-home__onboard-step.is-done {
background: var(--color-green-light);
border-color: color-mix(in srgb, var(--color-green) 30%, transparent);
}
.portal-home__onboard-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text-4);
}
.portal-home__onboard-step.is-done .portal-home__onboard-mark {
background: var(--color-green);
color: var(--color-text-on-accent);
border-color: var(--color-green);
}
.portal-home__onboard-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.portal-home__onboard-text strong {
font-size: 0.8125rem;
color: var(--color-text-1);
font-weight: 600;
}
.portal-home__onboard-text span {
font-size: 0.75rem;
color: var(--color-text-4);
}
/* Enterprise region strip */
.portal-home__regions {
display: grid;
@@ -14,6 +14,32 @@ export const ProTier: Story = { globals: { tier: "pro" } };
export const FreeTier: Story = { globals: { tier: "free" } };
export const EnterpriseTier: Story = { globals: { tier: "enterprise" } };
/**
* A subscribed org with a procurement deal underway: the deal-status hero
* replaces the setup checklist as the editor hero's footer (procurement is a
* bolt-on to any tier). Seeds an active trial snapshot on the SaaS mock.
*/
export const SubscribedInProcurement: Story = {
globals: { tier: "pro" },
parameters: {
msw: {
handlers: [
http.get("http://saas.mock/api/v1/procurement", () =>
HttpResponse.json({
dealId: 1,
stage: "trial",
trialStartedAt: "2026-07-01T00:00:00.000Z",
trialEndsAt: "2026-07-21T00:00:00.000Z",
trialExtensionsUsed: 0,
licensed: true,
latestQuote: null,
}),
),
],
},
},
};
export const SlowUsage: Story = {
globals: { tier: "pro" },
parameters: {
+11 -122
View File
@@ -14,21 +14,19 @@ import { useView, type ViewId } from "@portal/contexts/ViewContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchHomeKpis,
fetchOnboarding,
fetchRegionHealth,
fetchUsageSeries,
type KpiEntry,
type OnboardingStep,
type RegionHealth,
type UsageSeriesResponse,
} from "@portal/api/home";
import { WelcomeCarousel } from "@portal/components/WelcomeCarousel";
import { HomeHero } from "@portal/components/HomeHero";
import { HomeGreeting } from "@portal/components/HomeGreeting";
import { PopularUseCases } from "@portal/components/PopularUseCases";
import { UsageAreaChart } from "@portal/components/UsageAreaChart";
import { RecentActivity } from "@portal/components/RecentActivity";
import { SingleOpRunner } from "@portal/components/SingleOpRunner";
import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip";
import { ProcurementHome } from "@portal/components/procurement/ProcurementHome";
import { PolicySummary } from "@portal/components/PolicySummary";
import { PipelineForkWizard } from "@portal/components/PipelineForkWizard";
import "@portal/views/Home.css";
@@ -234,117 +232,6 @@ function QuickActions({ onTryOp }: { onTryOp: () => void }) {
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Free-tier onboarding checklist */
/* ──────────────────────────────────────────────────────────────────────── */
function FreeOnboarding({ onTryOp }: { onTryOp: () => void }) {
const { t } = useTranslation();
const { setActiveView } = useView();
const state = useAsync<OnboardingStep[]>(() => fetchOnboarding(), []);
const { data: steps } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const doneCount = steps?.filter((s) => s.done).length ?? 0;
function renderCta(step: OnboardingStep) {
if (step.done) {
return (
<Button size="sm" variant="tertiary" onClick={onTryOp}>
{t("portal.home.onboarding.runAgain")}
</Button>
);
}
if (!step.cta) return null;
if (step.cta.kind === "try-op") {
return (
<Button size="sm" variant="secondary" onClick={onTryOp}>
{t("portal.home.onboarding.start")}
</Button>
);
}
const target = step.cta.target;
return (
<Button
size="sm"
variant="secondary"
onClick={() => setActiveView(target as ViewId)}
>
{t("portal.home.onboarding.start")}
</Button>
);
}
return (
<Card padding="loose" className="portal-home__onboard">
<div className="portal-home__onboard-head">
<div>
<h2 className="portal-home__onboard-title">
{t("portal.home.onboarding.title")}
</h2>
<p className="portal-home__onboard-sub">
{t("portal.home.onboarding.subtitle")}
</p>
</div>
{steps && steps.length > 0 && (
<StatusBadge tone="info" size="sm">
{t("portal.home.onboarding.progress", {
done: doneCount,
total: steps.length,
})}
</StatusBadge>
)}
</div>
{isLoading && (
<ol className="portal-home__onboard-list" aria-hidden>
{Array.from({ length: 4 }).map((_, i) => (
<li
key={`skel-${i}`}
className="portal-home__onboard-step is-loading"
>
<span className="portal-home__onboard-mark" />
<div className="portal-home__onboard-text">
<div className="portal-home__onboard-skel" />
</div>
</li>
))}
</ol>
)}
{isEmpty && (
<EmptyState
size="compact"
title={t("portal.home.onboarding.empty.title")}
description={t("portal.home.onboarding.empty.description")}
/>
)}
{steps && steps.length > 0 && (
<ol className="portal-home__onboard-list">
{steps.map((s, i) => (
<li
key={s.id}
className={
"portal-home__onboard-step" + (s.done ? " is-done" : "")
}
>
<span className="portal-home__onboard-mark">
{s.done ? "✓" : i + 1}
</span>
<div className="portal-home__onboard-text">
<strong>{s.title}</strong>
<span>{s.blurb}</span>
</div>
{renderCta(s)}
</li>
))}
</ol>
)}
</Card>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Tier KPI strip */
/* ──────────────────────────────────────────────────────────────────────── */
@@ -543,18 +430,18 @@ export function Home() {
return (
<div className="portal-home">
<WelcomeCarousel onTryOp={() => setRunnerOpen(true)} />
<ProcessingStatusStrip />
{/* Paid tiers open with a greeting; free opens straight with the banner. */}
{tier !== "free" && <HomeGreeting />}
<ProcurementHome />
{/* Per-tier hero. Its footer is the deal-status hero while a procurement
deal is underway (a bolt-on to any tier), otherwise the setup checklist. */}
<HomeHero tier={tier} />
{tier === "free" && (
<>
<ProcessingStatusStrip />
<TierKpiStrip />
<div className="portal-home__row">
<FreeOnboarding onTryOp={() => setRunnerOpen(true)} />
<QuickActions onTryOp={() => setRunnerOpen(true)} />
</div>
<QuickActions onTryOp={() => setRunnerOpen(true)} />
<PolicySummary />
<PipelineForkWizard />
<ProductGrid />
@@ -564,6 +451,7 @@ export function Home() {
{tier === "pro" && (
<>
<ProcessingStatusStrip />
<ChartSection />
<TierKpiStrip />
<div className="portal-home__row">
@@ -579,6 +467,7 @@ export function Home() {
{tier === "enterprise" && (
<>
<ProcessingStatusStrip />
<ChartSection />
<TierKpiStrip />
<EnterpriseRegions />
+14
View File
@@ -240,6 +240,20 @@ 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.
{
files: [
"editor/src/portal/components/EditorStatusCard.tsx",
"editor/src/portal/components/SetupChecklist.tsx",
],
rules: {
"no-restricted-syntax": ["error", ...mantineComponentImportRestrictions],
},
},
// Stricter rules that not all sub-folders are conformant to yet.
{
files: srcGlobs,