mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68f851e31f | ||
|
|
ad04260f53 | ||
|
|
e1dfa5b05a | ||
|
|
97de031a04 | ||
|
|
7aaf6ba754 | ||
|
|
4d4de9fffa | ||
|
|
8ddf2e41e2 | ||
|
|
f947e8e7c1 | ||
|
|
e60c271f30 | ||
|
|
2372282e3d | ||
|
|
79cddfd800 | ||
|
|
8d97cff8eb |
@@ -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"
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user