From a8bda9240c73b65cb421bd7fe74e65c67e8e6602 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:44:30 +0100 Subject: [PATCH] feat(portal): replace free-tier carousel with static welcome hero (#6901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this PR does Redesigns the portal home to match the marketing demo, across all tiers. - Swapped the old rotating welcome carousel for a static **welcome hero** on the free tier - Subscribed (Processor) + enterprise get a **deployed-editor hero** — shows the live instance (host, version, active users) with an *Open in browser* button, pulled from the real editor-deployment API (same data the Editor admin page uses) - Added a time-of-day **greeting** on the paid tiers - Rebuilt the **"Finish setting up" checklist** so it's real: the counts and tick-offs come from the actual policies + sources (a step is done when there's at least one), not hardcoded numbers - *Download the PDF Editor* → `https://stirling.com/download`; the other steps deep-link to Policies / Sources - **Procurement is now a bolt-on to any tier** — if a deal's in flight the deal-status hero drops into the hero's footer, otherwise you get the setup checklist - All new copy is translated (en-US) and it reuses the shared UI kit, icons and design tokens ## Tidy-ups / fixes found along the way - The subscribed hero was shadowing the real `/v1/editor/deployment` endpoint (broke the Editor admin page) — now reuses it - Renamed the hero's CSS namespace to `.portal-welcome` so it stops clashing with the procurement hero's `.portal-hero` - Refactored the merged procurement component into a shared `useProcurement` hook + banner + flow, so the deal hero can live inside the tier hero — `/procurement` route unchanged ## Screenshots pr-free pr-enterprise pr-subscribed --- ## Checklist ### General - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### UI Changes - [x] Screenshots demonstrating the UI changes are attached ### Testing - [x] Portal typecheck, ESLint and Prettier pass on the changed files - [x] Verified all states in Storybook and ran the app locally via `task dev:portal` --- .../public/locales/en-US/translation.toml | 87 ++-- frontend/editor/src/portal/MOCKS.md | 1 - frontend/editor/src/portal/api/home.ts | 7 - .../portal/components/EditorStatusCard.css | 118 +++++ .../components/EditorStatusCard.stories.tsx | 50 ++ .../portal/components/EditorStatusCard.tsx | 194 ++++++++ .../src/portal/components/HomeGreeting.tsx | 30 ++ .../editor/src/portal/components/HomeHero.tsx | 42 ++ .../src/portal/components/SetupChecklist.css | 188 +++++++ .../components/SetupChecklist.stories.tsx | 58 +++ .../src/portal/components/SetupChecklist.tsx | 218 +++++++++ .../src/portal/components/WelcomeBanner.css | 461 ++++++++++++++++++ .../components/WelcomeBanner.stories.tsx | 29 ++ .../src/portal/components/WelcomeBanner.tsx | 183 +++++++ .../src/portal/components/WelcomeCarousel.css | 234 --------- .../components/WelcomeCarousel.stories.tsx | 20 - .../src/portal/components/WelcomeCarousel.tsx | 232 --------- .../editor/src/portal/components/icons.tsx | 31 ++ .../procurement/ProcurementBanner.tsx | 79 +++ .../procurement/ProcurementFlow.tsx | 181 +++++++ .../procurement/ProcurementHome.tsx | 298 +---------- .../components/procurement/useProcurement.ts | 179 +++++++ .../editor/src/portal/mocks/editorDeploy.ts | 3 + .../editor/src/portal/mocks/handlers/home.ts | 6 - frontend/editor/src/portal/mocks/home.ts | 42 +- frontend/editor/src/portal/views/Home.css | 101 +--- .../editor/src/portal/views/Home.stories.tsx | 26 + frontend/editor/src/portal/views/Home.tsx | 133 +---- frontend/eslint.config.mjs | 14 + 29 files changed, 2176 insertions(+), 1069 deletions(-) create mode 100644 frontend/editor/src/portal/components/EditorStatusCard.css create mode 100644 frontend/editor/src/portal/components/EditorStatusCard.stories.tsx create mode 100644 frontend/editor/src/portal/components/EditorStatusCard.tsx create mode 100644 frontend/editor/src/portal/components/HomeGreeting.tsx create mode 100644 frontend/editor/src/portal/components/HomeHero.tsx create mode 100644 frontend/editor/src/portal/components/SetupChecklist.css create mode 100644 frontend/editor/src/portal/components/SetupChecklist.stories.tsx create mode 100644 frontend/editor/src/portal/components/SetupChecklist.tsx create mode 100644 frontend/editor/src/portal/components/WelcomeBanner.css create mode 100644 frontend/editor/src/portal/components/WelcomeBanner.stories.tsx create mode 100644 frontend/editor/src/portal/components/WelcomeBanner.tsx delete mode 100644 frontend/editor/src/portal/components/WelcomeCarousel.css delete mode 100644 frontend/editor/src/portal/components/WelcomeCarousel.stories.tsx delete mode 100644 frontend/editor/src/portal/components/WelcomeCarousel.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx create mode 100644 frontend/editor/src/portal/components/procurement/useProcurement.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 31ee42a8a2..55397ccb9e 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/portal/MOCKS.md b/frontend/editor/src/portal/MOCKS.md index 17d6c68d55..0cd41ee9a8 100644 --- a/frontend/editor/src/portal/MOCKS.md +++ b/frontend/editor/src/portal/MOCKS.md @@ -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` | diff --git a/frontend/editor/src/portal/api/home.ts b/frontend/editor/src/portal/api/home.ts index e7ddfea910..467fd6a66a 100644 --- a/frontend/editor/src/portal/api/home.ts +++ b/frontend/editor/src/portal/api/home.ts @@ -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 { export async function fetchRegionHealth(): Promise { return apiClient.local.json("/v1/regions/health"); } - -/** GET /v1/onboarding (Free) */ -export async function fetchOnboarding(): Promise { - return apiClient.local.json("/v1/onboarding"); -} diff --git a/frontend/editor/src/portal/components/EditorStatusCard.css b/frontend/editor/src/portal/components/EditorStatusCard.css new file mode 100644 index 0000000000..313d283d6a --- /dev/null +++ b/frontend/editor/src/portal/components/EditorStatusCard.css @@ -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); +} diff --git a/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx b/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx new file mode 100644 index 0000000000..84126547eb --- /dev/null +++ b/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx @@ -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 = { + title: "Portal/Home/EditorStatusCard", + component: EditorStatusCard, + parameters: { layout: "padded" }, + globals: { tier: "pro" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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: , + }, +}; + +/** + * 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: , + }, + parameters: { + msw: { + handlers: [ + http.get("/v1/editor/deployment", () => + HttpResponse.json({ message: "not found" }, { status: 404 }), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/components/EditorStatusCard.tsx b/frontend/editor/src/portal/components/EditorStatusCard.tsx new file mode 100644 index 0000000000..9c97041260 --- /dev/null +++ b/frontend/editor/src/portal/components/EditorStatusCard.tsx @@ -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 ( + + + + + + ); +} + +/** 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( + () => 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 ? ( +
+ {footer} +
+ ) : null; + } + + return ( +
+
+
+ +
+ +
+ {!ready || !view ? ( + <> + + + + ) : ( + <> +
+ + {t("portal.home.editor.name")} + + {!hideChips && ( + <> + + + + )} +
+
+ {view.host} + {view.meta.map((item, i) => ( + + · + {item} + + ))} +
+ + )} +
+ +
+ +
+
+ + {footer &&
{footer}
} +
+ ); +} diff --git a/frontend/editor/src/portal/components/HomeGreeting.tsx b/frontend/editor/src/portal/components/HomeGreeting.tsx new file mode 100644 index 0000000000..0469015e82 --- /dev/null +++ b/frontend/editor/src/portal/components/HomeGreeting.tsx @@ -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 ( +
+

{greeting}

+

{date}

+
+ ); +} diff --git a/frontend/editor/src/portal/components/HomeHero.tsx b/frontend/editor/src/portal/components/HomeHero.tsx new file mode 100644 index 0000000000..643703f447 --- /dev/null +++ b/frontend/editor/src/portal/components/HomeHero.tsx @@ -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 ? ( + + ) : ( + + ); + + return ( + <> + {tier === "free" ? ( + + ) : ( + + )} + + + ); +} diff --git a/frontend/editor/src/portal/components/SetupChecklist.css b/frontend/editor/src/portal/components/SetupChecklist.css new file mode 100644 index 0000000000..b49744774e --- /dev/null +++ b/frontend/editor/src/portal/components/SetupChecklist.css @@ -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; +} diff --git a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx b/frontend/editor/src/portal/components/SetupChecklist.stories.tsx new file mode 100644 index 0000000000..ba8014921d --- /dev/null +++ b/frontend/editor/src/portal/components/SetupChecklist.stories.tsx @@ -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 = { + title: "Portal/Home/SetupChecklist", + component: SetupChecklist, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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: [] }), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/components/SetupChecklist.tsx b/frontend/editor/src/portal/components/SetupChecklist.tsx new file mode 100644 index 0000000000..bc41c30dd6 --- /dev/null +++ b/frontend/editor/src/portal/components/SetupChecklist.tsx @@ -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 ( +
+
+ + {t("portal.home.onboarding.enterprise.tag")} + +

+ {t("portal.home.onboarding.enterprise.lead")}{" "} + {t("portal.home.onboarding.enterprise.body")} +

+
+ +
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* "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(() => { + 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 ( +
+ {loading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+
+
+
+ ))} +
+ )} + + {!loading && !dismissed && ( + <> +
+ + {t("portal.home.onboarding.title")} + + +
+ +
    + {steps.map((s) => ( +
  1. + +
  2. + ))} +
+ + )} + + +
+ ); +} diff --git a/frontend/editor/src/portal/components/WelcomeBanner.css b/frontend/editor/src/portal/components/WelcomeBanner.css new file mode 100644 index 0000000000..87d284495f --- /dev/null +++ b/frontend/editor/src/portal/components/WelcomeBanner.css @@ -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); +} diff --git a/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx b/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx new file mode 100644 index 0000000000..0162d5f262 --- /dev/null +++ b/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx @@ -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 = { + title: "Portal/Home/WelcomeBanner", + component: WelcomeBanner, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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: , + }, +}; diff --git a/frontend/editor/src/portal/components/WelcomeBanner.tsx b/frontend/editor/src/portal/components/WelcomeBanner.tsx new file mode 100644 index 0000000000..8e9f71af29 --- /dev/null +++ b/frontend/editor/src/portal/components/WelcomeBanner.tsx @@ -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 ( +
+ {RAIL_ICONS.map((Icon, i) => ( + + + + ))} +
+ ); +} + +function WelcomeEditorCard() { + return ( +
+
+
+ + + +
+
+ + + Vulnerability Assessment Report CVE-2026-1847.pdf + + · + 18 pages +
+ 1 / 18 +
+ +
+ +
+
+ SECURITY ASSESSMENT + CRITICAL +
+
+ Vulnerability Assessment Report CVE-2026-1847 +
+
+
+ + + Assessment Date:{" "} + + February 2026 + + + + Classification:{" "} + + FOUO + +
+
EXECUTIVE SUMMARY
+

+ This assessment identifies critical vulnerabilities in the target + environment and provides actionable remediation guidance prioritized + by risk severity. +

+
KEY FINDINGS
+
+ 3 Critical + vulnerabilities identified +
+
+ 7 High-severity issues + requiring attention +
+
+ +
+
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* 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 ( +
+
+
+
+
+
+
+ +
+ + {t("portal.welcome.badge")} + +

+ {t("portal.welcome.title")}{" "} + + {t("portal.welcome.titleAccent")} + +

+

{t("portal.welcome.subtitle")}

+
+ + +
+

{t("portal.welcome.perks")}

+
+ +
+ +
+
+ + {footer &&
{footer}
} +
+ ); +} diff --git a/frontend/editor/src/portal/components/WelcomeCarousel.css b/frontend/editor/src/portal/components/WelcomeCarousel.css deleted file mode 100644 index 49a7d5fcc3..0000000000 --- a/frontend/editor/src/portal/components/WelcomeCarousel.css +++ /dev/null @@ -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; -} diff --git a/frontend/editor/src/portal/components/WelcomeCarousel.stories.tsx b/frontend/editor/src/portal/components/WelcomeCarousel.stories.tsx deleted file mode 100644 index 1db5790fcf..0000000000 --- a/frontend/editor/src/portal/components/WelcomeCarousel.stories.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { WelcomeCarousel } from "@portal/components/WelcomeCarousel"; - -const meta: Meta = { - title: "Portal/Home/WelcomeCarousel", - component: WelcomeCarousel, - args: { onTryOp: () => console.log("try op") }, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const AutoRotating: Story = {}; diff --git a/frontend/editor/src/portal/components/WelcomeCarousel.tsx b/frontend/editor/src/portal/components/WelcomeCarousel.tsx deleted file mode 100644 index 42af5a6abb..0000000000 --- a/frontend/editor/src/portal/components/WelcomeCarousel.tsx +++ /dev/null @@ -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 ( -
- - {t("portal.welcome.ornament.editor.critical")} - -
- Vulnerability Assessment Report -
-
CVE-2026-1847 · 12 pages
-
- {t("portal.welcome.ornament.editor.signed")} - · - {t("portal.welcome.ornament.editor.ocrClean")} - · - {t("portal.welcome.ornament.editor.schemaMatch")} -
-
- ); -} - -function PlatformOrnament() { - return ( -
-      POST{" "}
-      /v1/secure
-      {"\n"}
-      Authorization:{" "}
-      Bearer sk_live_a3f8…
-      {"\n"}
-      file:{" "}
-      
-        federal_CUI_contract.pdf
-      
-      {"\n"}
-      pipeline:{" "}
-      cui-redact-v2
-    
- ); -} - -function AgentOrnament() { - return ( -
-
- KYC Processor - 94% -
-
- Eval pass rate · 28 test cases -
-
-
-
-
- 26 passed - 2 review - MCP · Claude -
-
- ); -} - -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: , - }, - { - id: "platform", - durationMs: 8000, - primary: { - labelKey: "portal.welcome.slides.platform.primary", - action: "try-op", - }, - secondary: { - labelKey: "portal.welcome.slides.platform.secondary", - target: "infrastructure", - }, - ornament: , - }, - { - id: "agents", - durationMs: 8000, - primary: { - labelKey: "portal.welcome.slides.agents.primary", - target: "sources", - }, - secondary: { - labelKey: "portal.welcome.slides.agents.secondary", - target: "docs", - }, - ornament: , - }, -]; - -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 ( -
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" - > -
-
-
- {t(`portal.welcome.slides.${slide.id}.eyebrow`)} -
-

- {t(`portal.welcome.slides.${slide.id}.title`)} -

-

- {t(`portal.welcome.slides.${slide.id}.sub`)} -

-
- - -
-
-
{slide.ornament}
-
- -
-
-
-
- -
- {SLIDES.map((s, i) => ( -
-
- ); -} diff --git a/frontend/editor/src/portal/components/icons.tsx b/frontend/editor/src/portal/components/icons.tsx index 51a8f0693c..c5e93b8f08 100644 --- a/frontend/editor/src/portal/components/icons.tsx +++ b/frontend/editor/src/portal/components/icons.tsx @@ -254,6 +254,16 @@ export function ProcurementIcon(props: IconProps) { ); } +export function DownloadIcon(props: IconProps) { + return ( + + + + + + ); +} + export function LinkIcon(props: IconProps) { return ( @@ -262,3 +272,24 @@ export function LinkIcon(props: IconProps) { ); } + +export function UserPlusIcon(props: IconProps) { + return ( + + + + + + + ); +} + +export function ExternalLinkIcon(props: IconProps) { + return ( + + + + + + ); +} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx new file mode 100644 index 0000000000..fffc2479be --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx @@ -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 ( + 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 ( + +
+ + {t("portal.procurement.upsell.homeBadge")} + +

+ {t("portal.procurement.upsell.homeHeadline")} + {t("portal.procurement.upsell.homeBody")} +

+
+ +
+ ); +} + +/** 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 ? ( + + ) : ( + + ); +} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx new file mode 100644 index 0000000000..011902c35d --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx @@ -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 ( + <> + setOpen(false)} + title={t("portal.procurement.title")} + subtitle={t("portal.procurement.subtitle")} + > + {error && ( + setError(null)} + > + {error} + + )} + + {!isLinked && ( + openLinkModal()} + > + {t("portal.procurement.link.cta")} + + } + /> + )} + + {isLinked && (loading || !started) && } + + {isLinked && started && ( + <> +
+ +
+ + {(editing || + (isDraft && (stage === "trial" || stage === "quote"))) && ( + + )} + + {!editing && isIssued && stage === "quote" && latest && ( + setEditing(true)} + /> + )} + + {!editing && stage === "security" && latest && ( + + )} + + {!editing && stage === "procurement" && latest && ( + + )} + + {!editing && stage === "active" && } + +
+ +
+ + )} +
+ + setExtra(null)} + /> + setExtra(null)} + /> + {data && ( + setExtra(null)} + snapshot={data} + busy={busy} + onExtend={async () => { + await onExtendTrial(); + setExtra(null); + }} + onCancel={async () => { + await onReset(); + setExtra(null); + }} + /> + )} + + ); +} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx b/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx index 641bd55599..1877e0bc3c 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx @@ -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( - () => (isLinked ? fetchSnapshot() : Promise.resolve(null)), - [isLinked], - ); - const [snap, setSnap] = useState(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(null); - const [error, setError] = useState(null); - const [extra, setExtra] = useState( - 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) { - 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 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 ? ( - setOpen(true)} - onKeyDocs={() => setExtra("docs")} - onInvite={() => setActiveView("users")} - onSchedule={() => setExtra("schedule")} - onManageTrial={() => setExtra("trial")} - onNavigate={setActiveView} - /> - ) : ( - -
- - {t("portal.procurement.upsell.homeBadge")} - -

- {t("portal.procurement.upsell.homeHeadline")} - {t("portal.procurement.upsell.homeBody")} -

-
- -
- ); - + const controller = useProcurement(autoOpen); return ( <> - {banner} - setOpen(false)} - title={t("portal.procurement.title")} - subtitle={t("portal.procurement.subtitle")} - > - {error && ( - setError(null)} - > - {error} - - )} - - {!isLinked && ( - openLinkModal()} - > - {t("portal.procurement.link.cta")} - - } - /> - )} - - {isLinked && (state.loading || !started) && } - - {isLinked && started && ( - <> -
- -
- - {(editing || - (isDraft && (stage === "trial" || stage === "quote"))) && ( - - )} - - {!editing && isIssued && stage === "quote" && latest && ( - setEditing(true)} - /> - )} - - {!editing && stage === "security" && latest && ( - - )} - - {!editing && stage === "procurement" && latest && ( - run(goLive)} - /> - )} - - {!editing && stage === "active" && } - -
- -
- - )} -
- - setExtra(null)} - /> - setExtra(null)} - /> - {data && ( - setExtra(null)} - snapshot={data} - busy={busy} - onExtend={async () => { - await onExtendTrial(); - setExtra(null); - }} - onCancel={async () => { - await onReset(); - setExtra(null); - }} - /> - )} + + ); } diff --git a/frontend/editor/src/portal/components/procurement/useProcurement.ts b/frontend/editor/src/portal/components/procurement/useProcurement.ts new file mode 100644 index 0000000000..70f64f8f30 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/useProcurement.ts @@ -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; +} + +export function useProcurement(autoOpen = false): ProcurementController { + const { t } = useTranslation(); + const isLinked = usePortalLinked(); + + const state = useAsync( + () => (isLinked ? fetchSnapshot() : Promise.resolve(null)), + [isLinked], + ); + const [snap, setSnap] = useState(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(null); + const [error, setError] = useState(null); + const [extra, setExtra] = useState(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) { + 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
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, + }; +} diff --git a/frontend/editor/src/portal/mocks/editorDeploy.ts b/frontend/editor/src/portal/mocks/editorDeploy.ts index 398ef69470..32d98432f2 100644 --- a/frontend/editor/src/portal/mocks/editorDeploy.ts +++ b/frontend/editor/src/portal/mocks/editorDeploy.ts @@ -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", }; } diff --git a/frontend/editor/src/portal/mocks/handlers/home.ts b/frontend/editor/src/portal/mocks/handlers/home.ts index d5ba2825a0..5f295a7267 100644 --- a/frontend/editor/src/portal/mocks/handlers/home.ts +++ b/frontend/editor/src/portal/mocks/handlers/home.ts @@ -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); - }), ]; diff --git a/frontend/editor/src/portal/mocks/home.ts b/frontend/editor/src/portal/mocks/home.ts index 125a25d336..373ae4adc1 100644 --- a/frontend/editor/src/portal/mocks/home.ts +++ b/frontend/editor/src/portal/mocks/home.ts @@ -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. diff --git a/frontend/editor/src/portal/views/Home.css b/frontend/editor/src/portal/views/Home.css index c2f8bf6f23..7a4a1f5a37 100644 --- a/frontend/editor/src/portal/views/Home.css +++ b/frontend/editor/src/portal/views/Home.css @@ -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; diff --git a/frontend/editor/src/portal/views/Home.stories.tsx b/frontend/editor/src/portal/views/Home.stories.tsx index 6e7aec49a0..feed3f25da 100644 --- a/frontend/editor/src/portal/views/Home.stories.tsx +++ b/frontend/editor/src/portal/views/Home.stories.tsx @@ -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: { diff --git a/frontend/editor/src/portal/views/Home.tsx b/frontend/editor/src/portal/views/Home.tsx index fef96810e1..ff247271c6 100644 --- a/frontend/editor/src/portal/views/Home.tsx +++ b/frontend/editor/src/portal/views/Home.tsx @@ -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(() => 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 ( - - ); - } - if (!step.cta) return null; - if (step.cta.kind === "try-op") { - return ( - - ); - } - const target = step.cta.target; - return ( - - ); - } - - return ( - -
-
-

- {t("portal.home.onboarding.title")} -

-

- {t("portal.home.onboarding.subtitle")} -

-
- {steps && steps.length > 0 && ( - - {t("portal.home.onboarding.progress", { - done: doneCount, - total: steps.length, - })} - - )} -
- - {isLoading && ( -
    - {Array.from({ length: 4 }).map((_, i) => ( -
  1. - -
    -
    -
    -
  2. - ))} -
- )} - - {isEmpty && ( - - )} - - {steps && steps.length > 0 && ( -
    - {steps.map((s, i) => ( -
  1. - - {s.done ? "✓" : i + 1} - -
    - {s.title} - {s.blurb} -
    - {renderCta(s)} -
  2. - ))} -
- )} -
- ); -} - /* ──────────────────────────────────────────────────────────────────────── */ /* Tier KPI strip */ /* ──────────────────────────────────────────────────────────────────────── */ @@ -543,18 +430,18 @@ export function Home() { return (
- setRunnerOpen(true)} /> - + {/* Paid tiers open with a greeting; free opens straight with the banner. */} + {tier !== "free" && } - + {/* 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. */} + {tier === "free" && ( <> + -
- setRunnerOpen(true)} /> - setRunnerOpen(true)} /> -
+ setRunnerOpen(true)} /> @@ -564,6 +451,7 @@ export function Home() { {tier === "pro" && ( <> +
@@ -579,6 +467,7 @@ export function Home() { {tier === "enterprise" && ( <> + diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 99fa3bf55f..d954ca4006 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -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