Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68f851e31f | ||
|
|
ad04260f53 | ||
|
|
0692058602 | ||
|
|
8150d16b6f | ||
|
|
c8f238ae60 | ||
|
|
e1dfa5b05a | ||
|
|
8df49ac053 | ||
|
|
97de031a04 | ||
|
|
328cd8c664 | ||
|
|
7aaf6ba754 | ||
|
|
4d4de9fffa | ||
|
|
8ddf2e41e2 | ||
|
|
f947e8e7c1 | ||
|
|
e60c271f30 | ||
|
|
2372282e3d | ||
|
|
79cddfd800 | ||
|
|
8d97cff8eb |
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: feature-walkthrough
|
||||
description: >-
|
||||
Explain the full logic and process of the current branch end-to-end so someone
|
||||
with no prior knowledge of the task can understand, review, and reproduce it.
|
||||
Scopes the change from the branch diff, traces the flow across every layer it
|
||||
touches (frontend tool/hook/component, Java controller/service/endpoint, Python
|
||||
engine, config, i18n, tests), and produces a self-contained walkthrough document
|
||||
with Mermaid diagrams (sequence/flow/architecture), annotated file map with
|
||||
clickable references, before/after behavior, screenshots where a UI is involved,
|
||||
a "try it locally" section, and edge cases/risks. Use when asked for a feature or
|
||||
branch walkthrough, "explain what this branch does", a design/logic writeup, PR
|
||||
reviewer onboarding, or a hand-off doc. Pass --html to also emit a rendered HTML
|
||||
version; --no-screens to skip screenshots.
|
||||
argument-hint: "[branch-or-area] [--html] [--no-screens]"
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
# Feature / Branch Walkthrough
|
||||
|
||||
Turn the current branch into a walkthrough a newcomer can follow. Audience:
|
||||
**someone who has never seen this task**. Explain the *why*, the *flow*, and *how to
|
||||
try it* - not just a diff summary.
|
||||
|
||||
`$ARGUMENTS` may name a branch or area to focus on; default is the current branch
|
||||
vs `main`. Flags: `--html` (also emit a rendered HTML twin), `--no-screens`.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Scope the change
|
||||
- `git log --oneline main..HEAD` and `git diff --stat main...HEAD` for the shape.
|
||||
- Read the PR description / commit messages for stated intent. Do **not** invent
|
||||
history or motivation that isn't evidenced (state current behavior in present tense).
|
||||
- Classify touched files by layer:
|
||||
- **Frontend**: tools (`frontend/editor/src/core/components/tools/*` or `.../core/tools/*`),
|
||||
hooks (`core/hooks/tools/*`, `useToolOperation`), contexts, routes, i18n
|
||||
(`public/locales/en-US`).
|
||||
- **Java backend**: controllers (`.../controller/api/...`), services, models, config.
|
||||
- **Engine**: `engine/src/stirling/{agents,contracts,api,services}`.
|
||||
- **Config / build / docker / tests.**
|
||||
|
||||
### 2. Trace the flow end-to-end
|
||||
Follow one real path from user action to result. For a typical PDF tool that's:
|
||||
UI control → `useToolOperation` hook → `POST /api/v1/...` → Spring controller →
|
||||
service (PDFBox / LibreOffice / engine call) → response → review panel → download.
|
||||
Read the actual files so the narrative is true to the code, and collect the exact
|
||||
file:line anchors you'll cite.
|
||||
|
||||
### 3. Draw the diagrams (Mermaid)
|
||||
Pick what fits; usually 2-3 of:
|
||||
- **Sequence diagram** - request/response across frontend → backend → engine.
|
||||
- **Flowchart** - the core decision/branching logic of the feature.
|
||||
- **Architecture/component** - new pieces and how they wire to existing ones.
|
||||
- **State** - if the feature has modes/steps.
|
||||
Keep nodes labeled in plain language. Validate the Mermaid parses before shipping.
|
||||
|
||||
### 4. Screenshots (unless --no-screens)
|
||||
If a UI is involved, capture key states with the stubbed Playwright harness
|
||||
(see the **ui-walkthrough** skill and `files-page-screenshots.spec.ts` for the
|
||||
pattern) or, for before/after, capture `main` then the branch. Drop PNGs in
|
||||
`walkthrough/<feature>/` and reference them from the doc. For backend-only
|
||||
changes, show request/response examples (curl + JSON) instead.
|
||||
|
||||
### 5. Write the walkthrough
|
||||
Create `walkthrough/<feature>/FEATURE-WALKTHROUGH.md` with:
|
||||
1. **TL;DR** - what the branch does and who it's for, in 3-4 sentences.
|
||||
2. **Problem & approach** - what wasn't possible before; the chosen solution.
|
||||
3. **Architecture diagram** + 1-paragraph orientation.
|
||||
4. **End-to-end flow** - the sequence diagram + a numbered walk of each step,
|
||||
each citing the real file (clickable `path:line`).
|
||||
5. **Key files** - annotated map (path → one line on its role).
|
||||
6. **Logic deep-dive** - the flowchart + prose for the non-obvious decisions.
|
||||
7. **Behavior** - before vs after; screenshots or request/response examples.
|
||||
8. **Try it locally** - exact steps (`task dev` / `task dev:all`, the route to
|
||||
open or the curl to run, any env like `DOCKER_ENABLE_SECURITY` or a test
|
||||
license key). Make it copy-pasteable.
|
||||
9. **Edge cases, risks, follow-ups** - what's untested, known limits, gotchas.
|
||||
|
||||
Markdown is the primary deliverable - it renders with diagrams in GitHub PRs and
|
||||
IDEs, no build step, ideal for review.
|
||||
|
||||
### 6. If `--html`
|
||||
Also emit `walkthrough/<feature>/walkthrough.html`: the same content with Mermaid
|
||||
rendered via `mermaid.initialize({startOnLoad:true})` (script from CDN; note in
|
||||
the file that rendering diagrams needs network, the `.md` is the offline copy) and
|
||||
screenshots inline. Keep it self-contained otherwise.
|
||||
|
||||
### 7. Deliver
|
||||
Give the doc path and a short chat summary. Offer to `SendUserFile` it.
|
||||
|
||||
## Principles
|
||||
- **True to the code.** Every claim traces to a file you read; cite `path:line`.
|
||||
No fabricated migration/version history.
|
||||
- **Newcomer-first.** Define repo-specific terms (FileContext, `useToolOperation`,
|
||||
the `@app/*` layer cascade, stubbed vs live tests) on first use.
|
||||
- **Show, don't assert.** Prefer a diagram + a real example over adjectives.
|
||||
- Don't commit the `walkthrough/` output unless asked.
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
name: ui-before-after
|
||||
description: >-
|
||||
Analyse a branch or PR and automatically capture before/after screenshots of
|
||||
every UI surface its changes touch, then pixel-diff the pairs to surface what
|
||||
actually changed and assemble PR-ready before/after montage images. Generic and
|
||||
diff-driven: it derives the capture targets from the diff (changed tools/routes →
|
||||
URLs) instead of hand-listing screens, captures "before" from the base branch and
|
||||
"after" from the head, then keeps only the views that visually differ. Each
|
||||
comparison is auto-cropped to the region that actually changed (the bounding box of
|
||||
differing pixels), falling back to the full page only when the change spans most of
|
||||
it. Use for before/after shots, a visual diff of a branch/PR, "screenshots for the
|
||||
PR description", "show what changed in the UI", or a side-by-side of UI changes.
|
||||
Takes a PR number/URL (resolved via gh) or a branch; defaults to the current branch
|
||||
vs its base. Flags: --scope <selector>, --base <ref|merge-base>, --theme
|
||||
light|dark|both, --all (capture every route, not just changed), --no-autocrop,
|
||||
--pagewide <n>, --threshold <n>.
|
||||
argument-hint: "[PR# | PR-url | branch] [--scope <sel>] [--base <ref>] [--theme both] [--all] [--no-autocrop]"
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
# UI Before / After (generic visual diff)
|
||||
|
||||
Point it at a branch or PR; it figures out which UI changed, screenshots every
|
||||
affected surface **before** (base) and **after** (head), pixel-diffs the pairs, and
|
||||
montages the ones that actually changed into images for the PR description.
|
||||
|
||||
`$ARGUMENTS`: a PR number/URL, a branch, or nothing (current branch vs base).
|
||||
By default it captures the full viewport and auto-crops each comparison to the region
|
||||
that changed. Flags: `--scope <css>` (narrow the *capture* to a container, e.g.
|
||||
`[data-sidebar="tool-panel"]`, when you already know where the change is),
|
||||
`--no-autocrop` (keep full frames), `--pagewide <fraction>` (above this share of the
|
||||
page, skip cropping; default 0.6), `--base <ref|merge-base>`,
|
||||
`--theme light|dark|both`, `--all` (walk every route, not just changed),
|
||||
`--threshold <fraction>` (diff sensitivity, default 0.001).
|
||||
|
||||
Shares the capture harness with **ui-walkthrough** - read its SKILL.md for the
|
||||
stubbed-Playwright setup, worktree node_modules + `generate-icons`, the
|
||||
stale-`:5173` gotcha, and the dark-mode init-script. Bundled helpers:
|
||||
[capture-spec.template.ts](capture-spec.template.ts), [diff-shots.mjs](diff-shots.mjs),
|
||||
[montage-template.html](montage-template.html), [shoot-sections.mjs](shoot-sections.mjs).
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Resolve target + base
|
||||
```
|
||||
gh pr view <pr> --json number,title,headRefName,baseRefName,url,files # PR
|
||||
# or branch: base = merge-base(main, HEAD); head = HEAD
|
||||
gh pr diff <pr> --name-only # or: git diff --name-only <base>...HEAD
|
||||
```
|
||||
|
||||
### 2. Derive capture targets from the diff (the "analyse" step - no hand-listing)
|
||||
Map changed frontend files to URLs generically:
|
||||
- **Tools**: a changed `components/tools/<toolDir>/…` or `hooks/tools/<tool>/…` →
|
||||
toolId → URL via the repo's own rule `getToolUrlPath` in
|
||||
[toolsTaxonomy.ts:200](frontend/editor/src/core/data/toolsTaxonomy.ts): `/` + the
|
||||
id kebab-cased (`addPageNumbers` → `/add-page-numbers`).
|
||||
- **Pages/routes**: changed `filesPage/*` → `/files`, etc.
|
||||
- `--all`: enumerate every tool in the registry instead of just changed ones.
|
||||
Write `frontend/editor/screenshots/ui-diff/targets.json` =
|
||||
`[{ "id":"compress", "url":"/compress", "name":"Compress" }]`. This is what makes
|
||||
it generic - the spec never names a tool.
|
||||
|
||||
### 3. Capture AFTER (head) then BEFORE (base)
|
||||
Copy [capture-spec.template.ts](capture-spec.template.ts) →
|
||||
`src/core/tests/stubbed/ui-before-after.spec.ts` (it loops `targets.json`, seeds a
|
||||
sample PDF so file-dependent panels render, navigates to each URL, and screenshots
|
||||
the full viewport - or the `--scope` container if given). Ensure the harness is ready
|
||||
(node_modules + icons).
|
||||
```
|
||||
# after = current head
|
||||
cd frontend/editor && PR_SHOT_SIDE=after PR_SHOT_THEME=light \
|
||||
npx playwright test --project=stubbed ui-before-after.spec.ts
|
||||
# before = base, in an isolated worktree (copy the spec + targets.json in)
|
||||
git worktree add ../ba-base origin/<baseRefName> # or the merge-base
|
||||
# set up its frontend, copy spec + screenshots/ui-diff/targets.json across, then:
|
||||
cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
|
||||
npx playwright test --project=stubbed ui-before-after.spec.ts
|
||||
# copy its screenshots/ui-diff/before/ back next to after/. Repeat with
|
||||
# PR_SHOT_THEME=dark if --theme includes dark. Remove worktree when done.
|
||||
```
|
||||
|
||||
### 4. Auto-diff (surface what changed)
|
||||
```
|
||||
cd frontend/editor && node <skill>/diff-shots.mjs \
|
||||
screenshots/ui-diff/before screenshots/ui-diff/after screenshots/ui-diff
|
||||
```
|
||||
Produces `diff-report.json` classifying each view `unchanged | changed | added |
|
||||
removed`. For each changed view it computes the bounding box of differing pixels and
|
||||
writes cropped `__before_crop.png` / `__after_crop.png` / `__diff.png` to that region
|
||||
(+ padding) - **unless** the change covers more than `--pagewide` of the frame, where
|
||||
it keeps the full frame (`pageWide:true`). Drop `unchanged` - that's the noise the
|
||||
user doesn't want.
|
||||
|
||||
### 5. Montage the changes
|
||||
Build the manifest from the non-unchanged entries (group by tab/tool; each becomes a
|
||||
state row with before/after). For changed views use the cropped `cropBefore` /
|
||||
`cropAfter` from `diff-report.json` (tight on the affected region; full frame when
|
||||
`pageWide`); `added`/`removed` render the "not present" placeholder. Fill
|
||||
[montage-template.html](montage-template.html) (replace the `window.__BA__` data
|
||||
block; base64-inline the PNGs for portability), then render one PNG per section with
|
||||
[shoot-sections.mjs](shoot-sections.mjs). Optionally include the `__diff.png` overlay
|
||||
as a third column.
|
||||
|
||||
### 6. Deliver
|
||||
Output the `montage_<tab>.png` files + a short summary (N changed / added / removed,
|
||||
M unchanged skipped) and a paste-ready Markdown block. GitHub has no PR-body image
|
||||
API, so tell the user to drag the PNGs into the description. Do **not** post to the
|
||||
PR.
|
||||
|
||||
## Gotchas
|
||||
- Two installs (base worktree + head); junction main's node_modules only if its deps
|
||||
match that ref, else `npm ci` (see ui-walkthrough's stale-dep note).
|
||||
- A view that errors on one side (refactored/removed) → that side is missing; the
|
||||
diff marks it added/removed rather than failing the run.
|
||||
- Pixel diff needs equal dimensions, so capture at a fixed viewport (the template
|
||||
does); a view whose size changed is reported as "changed (dimensions differ)",
|
||||
uncropped.
|
||||
- Auto-crop uses a single bounding box, so two far-apart changes give one large crop
|
||||
(or trip `--pagewide`); narrow with `--scope` if that happens.
|
||||
- `getToolUrlPath` is the source of truth for tool URLs - use it, don't guess slugs.
|
||||
- Don't commit `screenshots/`, the throwaway spec, or the base worktree.
|
||||
@@ -0,0 +1,67 @@
|
||||
// Generic before/after capturer. NOT app-specific: it walks a targets.json that
|
||||
// the ui-before-after skill generates from the branch/PR diff, so nothing here is
|
||||
// hand-listed. Copy to src/core/tests/stubbed/ui-before-after.spec.ts, then run
|
||||
// once per (side, theme):
|
||||
// PR_SHOT_SIDE=after PR_SHOT_THEME=light \
|
||||
// npx playwright test --project=stubbed ui-before-after.spec.ts
|
||||
//
|
||||
// targets.json shape: [{ "id":"compress", "url":"/compress", "name":"Compress",
|
||||
// "needsFile": true }]
|
||||
import { test } from "@app/tests/helpers/stub-test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const SIDE = process.env.PR_SHOT_SIDE ?? "after";
|
||||
const THEME = process.env.PR_SHOT_THEME ?? "light";
|
||||
// Capture the full viewport by default so the affected region is in frame
|
||||
// wherever it is; diff-shots.mjs crops each comparison to what actually changed.
|
||||
// Set PR_SHOT_SCOPE to a selector to narrow the capture to one container.
|
||||
const SCOPE = process.env.PR_SHOT_SCOPE ?? "";
|
||||
const ROOT = path.resolve(process.cwd(), "screenshots", "ui-diff");
|
||||
const OUT = path.join(ROOT, SIDE);
|
||||
// A tiny sample PDF so file-dependent tool panels render. Point at a real fixture.
|
||||
const SAMPLE_PDF = process.env.PR_SHOT_SAMPLE ?? "src/core/tests/test-fixtures/sample.pdf";
|
||||
|
||||
type Target = { id: string; url: string; name?: string; needsFile?: boolean };
|
||||
const targets: Target[] = JSON.parse(fs.readFileSync(path.join(ROOT, "targets.json"), "utf-8"));
|
||||
|
||||
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 }, seedJwt: true });
|
||||
|
||||
async function applyTheme(page: Page): Promise<void> {
|
||||
if (THEME !== "dark") return;
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("mantine-color-scheme", "dark");
|
||||
localStorage.setItem("mantine-color-scheme-value", "dark");
|
||||
});
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
}
|
||||
|
||||
async function seedFile(page: Page): Promise<void> {
|
||||
if (!fs.existsSync(SAMPLE_PDF)) return;
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("files-button").click().catch(() => {});
|
||||
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF).catch(() => {});
|
||||
await page.locator(".file-sidebar-file-item").first().isVisible({ timeout: 8_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
for (const t of targets) {
|
||||
// One test per target so a single failure doesn't drop the rest.
|
||||
test(`${SIDE}/${THEME} ${t.id}`, async ({ page }) => {
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
await applyTheme(page);
|
||||
if (t.needsFile !== false) await seedFile(page);
|
||||
await page.goto(t.url, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(400); // settle Mantine portals/transitions
|
||||
const shot = path.join(OUT, `${t.id}__${THEME}.png`);
|
||||
if (SCOPE) {
|
||||
const scope = page.locator(SCOPE).first();
|
||||
if (await scope.isVisible({ timeout: 8_000 }).catch(() => false)) {
|
||||
await scope.screenshot({ path: shot });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Full viewport (fixed size → stable dimensions for pixel diffing).
|
||||
await page.screenshot({ path: shot });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Auto-diff before/ vs after/ screenshots, classify each as
|
||||
// unchanged | changed | added | removed, and CROP each changed pair to the
|
||||
// affected region (bounding box of differing pixels + padding) - unless the
|
||||
// change spans most of the page, in which case the full frame is kept.
|
||||
// Run from frontend/editor (so deps resolve):
|
||||
// node <skill>/diff-shots.mjs <beforeDir> <afterDir> [outDir]
|
||||
// Env:
|
||||
// DIFF_THRESHOLD min fraction of differing pixels to count as changed (default 0.001)
|
||||
// DIFF_PAD padding px around the affected region (default 24)
|
||||
// DIFF_PAGEWIDE if affected bbox area / image area exceeds this, keep full frame (default 0.6)
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(path.join(process.cwd(), "noop.js"));
|
||||
const pm = require("pixelmatch");
|
||||
const pixelmatch = pm.default || pm;
|
||||
const { PNG } = require("pngjs");
|
||||
|
||||
const beforeDir = path.resolve(process.argv[2]);
|
||||
const afterDir = path.resolve(process.argv[3]);
|
||||
const outDir = path.resolve(process.argv[4] || afterDir);
|
||||
const THRESHOLD = Number(process.env.DIFF_THRESHOLD ?? "0.001");
|
||||
const PAD = Number(process.env.DIFF_PAD ?? "24");
|
||||
const PAGEWIDE = Number(process.env.DIFF_PAGEWIDE ?? "0.6");
|
||||
|
||||
const read = (p) => PNG.sync.read(fs.readFileSync(p));
|
||||
const isShot = (f) => f.endsWith(".png") && !/__(diff|before_crop|after_crop)\.png$/.test(f);
|
||||
const list = (d) => (fs.existsSync(d) ? fs.readdirSync(d).filter(isShot) : []);
|
||||
const names = [...new Set([...list(beforeDir), ...list(afterDir)])].sort();
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
function cropPNG(src, x, y, w, h) {
|
||||
const out = new PNG({ width: w, height: h });
|
||||
PNG.bitblt(src, out, x, y, w, h, 0, 0);
|
||||
return out;
|
||||
}
|
||||
const writePNG = (p, png) => fs.writeFileSync(p, PNG.sync.write(png));
|
||||
|
||||
// Bounding box of differing pixels using a diff mask (alpha>0 where changed).
|
||||
function changedBBox(before, after, w, h) {
|
||||
const mask = new PNG({ width: w, height: h });
|
||||
pixelmatch(before.data, after.data, mask.data, w, h, { threshold: 0.1, diffMask: true });
|
||||
let minX = w, minY = h, maxX = -1, maxY = -1, count = 0;
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (mask.data[(y * w + x) * 4 + 3] > 0) {
|
||||
count++;
|
||||
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxX < 0 ? null : { minX, minY, maxX, maxY, count };
|
||||
}
|
||||
|
||||
const report = [];
|
||||
for (const name of names) {
|
||||
const id = name.replace(/\.png$/, "");
|
||||
const bp = path.join(beforeDir, name), ap = path.join(afterDir, name);
|
||||
const hasB = fs.existsSync(bp), hasA = fs.existsSync(ap);
|
||||
if (hasB && !hasA) { report.push({ id, status: "removed", before: bp }); continue; }
|
||||
if (!hasB && hasA) { report.push({ id, status: "added", after: ap }); continue; }
|
||||
|
||||
const before = read(bp), after = read(ap);
|
||||
if (before.width !== after.width || before.height !== after.height) {
|
||||
report.push({ id, status: "changed", note: "dimensions differ", before: bp, after: ap });
|
||||
continue;
|
||||
}
|
||||
const w = after.width, h = after.height;
|
||||
const overlay = new PNG({ width: w, height: h });
|
||||
const px = pixelmatch(before.data, after.data, overlay.data, w, h, { threshold: 0.1 });
|
||||
const ratio = px / (w * h);
|
||||
if (ratio <= THRESHOLD) { report.push({ id, status: "unchanged", ratio: Number(ratio.toFixed(5)), before: bp, after: ap }); continue; }
|
||||
|
||||
const box = changedBBox(before, after, w, h);
|
||||
// Pad + clamp the affected region.
|
||||
const x = Math.max(0, box.minX - PAD), y = Math.max(0, box.minY - PAD);
|
||||
const x2 = Math.min(w, box.maxX + 1 + PAD), y2 = Math.min(h, box.maxY + 1 + PAD);
|
||||
const bw = x2 - x, bh = y2 - y;
|
||||
const pageWide = (bw * bh) / (w * h) > PAGEWIDE;
|
||||
|
||||
const entry = { id, status: "changed", ratio: Number(ratio.toFixed(5)), before: bp, after: ap, pageWide };
|
||||
if (pageWide) {
|
||||
// Change spans most of the page - keep the full frame, full overlay.
|
||||
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, overlay);
|
||||
entry.diff = dp;
|
||||
} else {
|
||||
entry.bbox = { x, y, w: bw, h: bh };
|
||||
const cb = path.join(outDir, `${id}__before_crop.png`); writePNG(cb, cropPNG(before, x, y, bw, bh));
|
||||
const ca = path.join(outDir, `${id}__after_crop.png`); writePNG(ca, cropPNG(after, x, y, bw, bh));
|
||||
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, cropPNG(overlay, x, y, bw, bh));
|
||||
entry.cropBefore = cb; entry.cropAfter = ca; entry.diff = dp;
|
||||
}
|
||||
report.push(entry);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(outDir, "diff-report.json"), JSON.stringify(report, null, 2));
|
||||
const changed = report.filter((r) => r.status !== "unchanged");
|
||||
console.log(`diffed ${report.length} view(s): ${changed.length} changed/added/removed, ${report.length - changed.length} unchanged`);
|
||||
for (const r of changed) {
|
||||
const tail = r.status !== "changed" ? ""
|
||||
: r.pageWide ? " (page-wide → full frame)"
|
||||
: ` (${(r.ratio * 100).toFixed(2)}%, cropped to ${r.bbox.w}×${r.bbox.h})`;
|
||||
console.log(` ${r.status.padEnd(9)} ${r.id}${tail}${r.note ? " - " + r.note : ""}`);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Build EXAMPLE.html from montage-template.html using REAL files-page shots as
|
||||
stand-in before/after pairs (layout demo, not an actual PR diff). Inlines PNGs as
|
||||
data URIs so the HTML is portable. Run: python make_example.py"""
|
||||
import base64
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
SHOTS = pathlib.Path(
|
||||
r"C:\Users\systo\git\Stirling-PDFNew\.claude\worktrees\kind-faraday-522a30"
|
||||
r"\frontend\editor\screenshots\files-page"
|
||||
)
|
||||
|
||||
|
||||
def uri(fname):
|
||||
p = SHOTS / fname
|
||||
return "data:image/png;base64," + base64.b64encode(p.read_bytes()).decode() if p.exists() else None
|
||||
|
||||
|
||||
data = {
|
||||
"pr": "DEMO",
|
||||
"title": "EXAMPLE — before/after montage (layout demo, real Files-page shots; not a real PR diff)",
|
||||
"base": "main", "head": "demo-branch",
|
||||
"cropSelector": "[data-sidebar=\"tool-panel\"] (real runs crop to the side; these demo shots are full-page)",
|
||||
"tabs": [
|
||||
{"id": "files", "title": "Files page", "ctx": "Each row = one flow state; left = base branch, right = this PR.",
|
||||
"states": [
|
||||
{"name": "Empty folder", "before": uri("01_empty_state_ctas.png"), "after": uri("02_empty_state_storage_off.png")},
|
||||
{"name": "Files + details panel", "before": uri("03_subtoolbar_with_files.png"), "after": uri("06_details_panel_save_to_server.png")},
|
||||
{"name": "Delete folder confirm", "before": None, "after": uri("19_delete_folder_dialog.png"), "note": "New in this PR"},
|
||||
]},
|
||||
{"id": "move", "title": "Move-to-folder dialog",
|
||||
"states": [
|
||||
{"name": "Dialog opened", "before": uri("07_move_dialog_collapsed.png"), "after": uri("08_move_dialog_create_folder_expanded.png")},
|
||||
{"name": "After folder created", "before": None, "after": uri("08b_move_dialog_after_create_folder.png"), "note": "New flow"},
|
||||
]},
|
||||
],
|
||||
}
|
||||
|
||||
tpl = (HERE / "montage-template.html").read_text(encoding="utf-8")
|
||||
out = re.sub(
|
||||
r"/\*__DATA__\*/.*?/\*__END__\*/",
|
||||
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
|
||||
tpl, count=1, flags=re.S,
|
||||
)
|
||||
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
|
||||
print("wrote", HERE / "EXAMPLE.html", "(", (HERE / "EXAMPLE.html").stat().st_size // 1024, "KB )")
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Before/After montage for a PR description. The ui-before-after skill replaces
|
||||
the JSON in the window.__BA__ data block below with the captured manifest, then
|
||||
screenshots each .tab-section (id="section-<tabId>") into a PNG to drag into the
|
||||
PR description. Self-contained; images may be relative paths or data URIs.
|
||||
|
||||
Data shape:
|
||||
{
|
||||
"pr":"6552","title":"...","base":"main","head":"feat/x",
|
||||
"cropSelector":"[data-sidebar=\"tool-panel\"]",
|
||||
"tabs":[
|
||||
{ "id":"sign","title":"Sign tool","states":[
|
||||
{"name":"Initial","before":"before/sign__initial.png","after":"after/sign__initial.png"},
|
||||
{"name":"Cert selected","before":null,"after":"after/sign__cert.png","note":"New in this PR"}
|
||||
]}
|
||||
]
|
||||
}
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Before / After</title>
|
||||
<style>
|
||||
:root { --bg:#ffffff; --ink:#0b0c0e; --muted:#6b7280; --line:#e5e7eb;
|
||||
--before:#6b7280; --after:#1f883d; --frame:#f3f4f6; --note:#b45309; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--ink);
|
||||
font:14px/1.5 -apple-system,"Segoe UI",Roboto,system-ui,sans-serif; }
|
||||
.wrap { max-width:1100px; margin:0 auto; padding:24px; }
|
||||
.doc-head { margin-bottom:8px; }
|
||||
.doc-head h1 { font-size:18px; margin:0 0 2px; }
|
||||
.doc-head .sub { color:var(--muted); font-size:12.5px; }
|
||||
.legend { display:flex; gap:14px; align-items:center; margin:10px 0 4px; font-size:12px; color:var(--muted); }
|
||||
.chip { font-size:10px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
|
||||
padding:2px 8px; border-radius:999px; color:#fff; }
|
||||
.chip.before { background:var(--before); } .chip.after { background:var(--after); }
|
||||
|
||||
.tab-section { border:1px solid var(--line); border-radius:14px; padding:18px 18px 8px;
|
||||
margin:18px 0; background:var(--bg); }
|
||||
.tab-section > h2 { font-size:16px; margin:0 0 2px; }
|
||||
.tab-section > .ctx { color:var(--muted); font-size:12px; margin-bottom:14px; }
|
||||
.state { margin-bottom:18px; }
|
||||
.state .name { font-weight:600; font-size:13.5px; margin-bottom:8px; display:flex; gap:8px; align-items:center; }
|
||||
.state .name .note { font-weight:500; color:var(--note); font-size:12px; }
|
||||
.pair { display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:start; }
|
||||
.cell { border:1px solid var(--line); border-radius:10px; overflow:hidden; background:var(--frame); }
|
||||
.cell .cap { display:flex; align-items:center; gap:8px; padding:7px 10px; border-bottom:1px solid var(--line);
|
||||
background:var(--bg); }
|
||||
.cell .cap .meta { color:var(--muted); font-size:11px; }
|
||||
.cell img { display:block; width:100%; height:auto; background:#fff; }
|
||||
.cell.empty .ph { display:flex; align-items:center; justify-content:center; height:160px; color:var(--muted);
|
||||
font-size:12.5px; text-align:center; padding:0 16px; }
|
||||
.single .pair { grid-template-columns:1fr; }
|
||||
.empty-doc { color:var(--muted); padding:40px; text-align:center; }
|
||||
@media (max-width:760px){ .pair{ grid-template-columns:1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap" id="root"></div>
|
||||
|
||||
<script id="data">
|
||||
window.__BA__ = /*__DATA__*/{"pr":"","title":"No data","base":"","head":"","cropSelector":"","tabs":[]}/*__END__*/;
|
||||
</script>
|
||||
<script>
|
||||
(function(){
|
||||
var D = window.__BA__ || { tabs: [] };
|
||||
var root = document.getElementById("root");
|
||||
function el(html){ var t=document.createElement("template"); t.innerHTML=html.trim(); return t.content.firstChild; }
|
||||
function esc(s){ return (s==null?"":String(s)).replace(/[&<>]/g, function(c){return {"&":"&","<":"<",">":">"}[c];}); }
|
||||
|
||||
function cell(kind, src){
|
||||
if (src) {
|
||||
return '<div class="cell"><div class="cap"><span class="chip '+kind+'">'+kind+'</span></div>'+
|
||||
'<img src="'+esc(src)+'" alt="'+kind+'"/></div>';
|
||||
}
|
||||
return '<div class="cell empty"><div class="cap"><span class="chip '+kind+'">'+kind+'</span>'+
|
||||
'<span class="meta">not present</span></div><div class="ph">No '+kind+' screenshot for this state</div></div>';
|
||||
}
|
||||
|
||||
var head = '<div class="doc-head"><h1>'+esc(D.title || ("PR #"+D.pr))+'</h1>'+
|
||||
'<div class="sub">Before / after · base <code>'+esc(D.base)+'</code> → head <code>'+esc(D.head)+'</code>'+
|
||||
(D.cropSelector ? ' · cropped to <code>'+esc(D.cropSelector)+'</code>' : '')+'</div></div>'+
|
||||
'<div class="legend"><span class="chip before">Before</span> base branch'+
|
||||
'<span class="chip after">After</span> this PR</div>';
|
||||
root.appendChild(el('<div>'+head+'</div>'));
|
||||
|
||||
if (!D.tabs || !D.tabs.length){ root.appendChild(el('<div class="empty-doc">No tabs captured yet.</div>')); return; }
|
||||
|
||||
D.tabs.forEach(function(tab){
|
||||
var states = (tab.states||[]).map(function(s){
|
||||
var onlyOne = (!s.before || !s.after);
|
||||
return '<div class="state'+(onlyOne?' ':'')+'">'+
|
||||
'<div class="name">'+esc(s.name)+(s.note?'<span class="note">'+esc(s.note)+'</span>':'')+'</div>'+
|
||||
'<div class="pair">'+cell("before", s.before)+cell("after", s.after)+'</div></div>';
|
||||
}).join("");
|
||||
var sec = '<section class="tab-section" id="section-'+esc(tab.id)+'">'+
|
||||
'<h2>'+esc(tab.title)+'</h2>'+
|
||||
(tab.ctx?'<div class="ctx">'+esc(tab.ctx)+'</div>':'')+
|
||||
states+'</section>';
|
||||
root.appendChild(el(sec));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
// Render each .tab-section of a montage HTML into its own PNG (the PR-ready image).
|
||||
// Run from frontend/editor (so @playwright/test resolves):
|
||||
// node <skill>/shoot-sections.mjs <montage.html> <outDir>
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(path.join(process.cwd(), "noop.js"));
|
||||
const { chromium } = require("@playwright/test");
|
||||
|
||||
const htmlPath = path.resolve(process.argv[2]);
|
||||
const outDir = path.resolve(process.argv[3] || path.dirname(htmlPath));
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 1200 }, deviceScaleFactor: 2 });
|
||||
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
|
||||
await page.waitForTimeout(250); // let images/fonts paint
|
||||
const ids = await page.$$eval(".tab-section", (els) => els.map((e) => e.id));
|
||||
if (!ids.length) { console.error("no .tab-section found"); process.exit(1); }
|
||||
for (const id of ids) {
|
||||
const name = id.replace(/^section-/, "");
|
||||
await page.locator("#" + id).screenshot({ path: path.join(outDir, `montage_${name}.png`) });
|
||||
console.log("wrote montage_" + name + ".png");
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: ui-walkthrough
|
||||
description: >-
|
||||
Full UI investigation of the current branch's feature. Enumerates every view
|
||||
and state (empty, populated, loading, error, each dialog/menu/panel, responsive
|
||||
breakpoints, light + dark + RTL), captures them with the stubbed Playwright
|
||||
harness, assembles a single-image HTML walkthrough with a global light/dark
|
||||
toggle slider, then runs two review passes: visual/consistency (alignment,
|
||||
spacing, professionalism, dark/light parity, contrast, truncation) and
|
||||
UX/ease-of-use (flow, discoverability, affordances, empty/error states,
|
||||
expectations). Use when asked for a UI walkthrough, screenshot review, design
|
||||
or QA pass, "find anywhere to make it easier/better for users", or before
|
||||
merging frontend work. Pass --fix to auto-apply safe frontend fixes and
|
||||
re-capture; --theme to limit themes; --no-rtl to skip RTL.
|
||||
argument-hint: "[feature/area] [--fix] [--theme light|dark|both] [--no-rtl] [--breakpoints]"
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
# UI Walkthrough
|
||||
|
||||
Produce a reviewable HTML walkthrough of a feature's UI in every state and theme,
|
||||
then critique it. Optionally auto-fix and re-capture.
|
||||
|
||||
`$ARGUMENTS` may name the feature/area to focus on. If empty, scope from the
|
||||
current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
|
||||
`--no-rtl`, `--breakpoints` (also capture phone/narrow widths).
|
||||
|
||||
## What this repo gives you (use it, don't reinvent)
|
||||
|
||||
- **Stubbed Playwright project** = backend-free screenshots via `page.route()` mocks.
|
||||
Reference implementation: `frontend/editor/src/core/tests/stubbed/files-page-screenshots.spec.ts`.
|
||||
It already shows the light / **dark** / **RTL** passes, JWT seeding, IndexedDB
|
||||
seeding, and dumping PNGs to a `screenshots/<area>/` folder. Copy its shape.
|
||||
- Helpers: `frontend/editor/src/core/tests/helpers/ui-helpers.ts`
|
||||
(`uploadFiles`, `openSettings`, `waitForModalOpen`, `dismissTourTooltip`, …)
|
||||
and the `stub-test-base` fixtures (`autoGoto`, `seedJwt`, `viewport`).
|
||||
- Config: `frontend/editor/playwright.config.ts` (run from `frontend/editor/`).
|
||||
- Report template: [report-template.html](report-template.html) - self-contained,
|
||||
one big image at a time, a global light/dark slider that flips every shot,
|
||||
thumbnail rail, prev/next + arrow keys, and a Findings tab.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Scope the feature
|
||||
- If `$ARGUMENTS` is empty: `git diff --name-only main...HEAD` and read the PR/commits.
|
||||
Identify changed pages, tools (`core/components/tools/<tool>` or `core/tools/<tool>`),
|
||||
dialogs, panels, and routes.
|
||||
- Enumerate **every view and state** to capture, e.g.:
|
||||
empty / populated / loading / error / disabled; each dialog, menu, popover, tooltip;
|
||||
each tab or step; selection + multi-select; success/result panel; and (if relevant)
|
||||
permission/role variants. Write the list down before capturing - it's the report's spine.
|
||||
|
||||
### 2. Prepare the harness (worktree-safe)
|
||||
Worktrees have no `node_modules` and no generated icons. From repo root:
|
||||
```
|
||||
cd frontend && npm ci # or junction main's node_modules (see memory)
|
||||
cd frontend/editor && node scripts/generate-icons.js
|
||||
```
|
||||
Kill any stale dev server first (it serves old modules):
|
||||
`Get-NetTCPConnection -LocalPort 5173 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`
|
||||
|
||||
### 3. Write the capture spec
|
||||
Create `frontend/editor/src/core/tests/stubbed/<feature>-walkthrough.spec.ts`,
|
||||
modeled on `files-page-screenshots.spec.ts`. For each enumerated view:
|
||||
- stub the APIs it needs, drive the UI to that state, wait on a real locator
|
||||
(not a fixed sleep), `await settle(page)` for Mantine portals, then
|
||||
`page.screenshot({ path: shotPath("NN_name_<theme>") })`.
|
||||
- Capture each view in **light and dark** (and RTL unless `--no-rtl`). Reuse the
|
||||
`enableDarkMode` / `enableRtl` init-script pattern from the reference spec
|
||||
(`localStorage["mantine-color-scheme"]="dark"` + `emulateMedia({colorScheme:"dark"})`).
|
||||
- Name shots `NN_<view>_<theme>.png` so light/dark pair up by suffix.
|
||||
- Prefer **stable test-ids** over translated accessible names (RTL/i18n breaks text locators).
|
||||
|
||||
Run it: `cd frontend/editor && npx playwright test --project=stubbed <feature>-walkthrough.spec.ts`.
|
||||
Add `--project=stubbed-firefox`/`-webkit` only if cross-browser layout matters.
|
||||
|
||||
### 4. Build the report
|
||||
- Copy `report-template.html` to `screenshots/<feature>/walkthrough.html` (so the
|
||||
relative `screenshots/...` image paths resolve, or rewrite paths to sit beside it).
|
||||
- Build the manifest and inject it: replace the JSON between the
|
||||
`/*__DATA__*/` … `/*__END__*/` markers with one `views[]` entry per view
|
||||
(`{id,title,light,dark,viewport,notes}`) and an empty `findings` object you'll
|
||||
fill in step 5. Keep `light`/`dark` as relative paths.
|
||||
- The toggle slider answers the "one big image + flip light/dark for all" request:
|
||||
it shows a single large screenshot, and switching the slider re-themes every view.
|
||||
|
||||
### 5. Review pass 1 - visual & consistency
|
||||
Open each screenshot (Read the PNG) and judge against the others:
|
||||
alignment & spacing rhythm, control placement, button hierarchy, typography,
|
||||
**light/dark parity** (contrast, invisible borders, washed-out text, wrong tokens),
|
||||
truncation/overflow, RTL mirroring, focus states, icon consistency, professional polish.
|
||||
Record each issue as a finding `{severity:high|med|low, view, title, detail, fix}`.
|
||||
|
||||
### 6. Review pass 2 - UX & ease of use
|
||||
Walk the flow as a first-time user: discoverability, number of steps, affordance
|
||||
clarity, empty-state guidance, error recovery, destructive-action confirmation,
|
||||
defaults, loading feedback, mobile reachability, accessible names, and whether the
|
||||
UI matches user expectations for this kind of tool. Record findings the same way.
|
||||
|
||||
Write both finding lists into the report's `findings.visual` / `findings.ux`,
|
||||
and add short per-view `notes`. Re-inject the manifest.
|
||||
|
||||
### 7. If `--fix`
|
||||
Only safe, self-contained frontend fixes (spacing, alignment, tokens, missing
|
||||
dark-mode colors, labels, aria, obvious copy). For each: edit the component/CSS,
|
||||
mark the finding `fixed:true` with what changed, then **re-run the spec** to
|
||||
re-capture the affected shots and regenerate the report. Run `task frontend:check`.
|
||||
Leave anything risky or ambiguous as a finding, not a change.
|
||||
|
||||
### 8. Deliver
|
||||
Tell the user the report path and give a tight chat summary: N views ×
|
||||
themes captured, top findings by severity, and (if `--fix`) what changed.
|
||||
Optionally `SendUserFile` the `walkthrough.html`.
|
||||
|
||||
## Gotchas
|
||||
- Stale `:5173` server serves old bundles - kill it before capturing (see step 2).
|
||||
- Missing `material-symbols-icons.json` → blank app → every shot times out. Run
|
||||
`generate-icons.js` first.
|
||||
- `await settle(page)` before shots or portals/transitions tear mid-capture.
|
||||
- Don't commit the generated `screenshots/` or the throwaway spec unless asked.
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Build a self-contained EXAMPLE.html from report-template.html with mock
|
||||
light/dark screenshots, so the viewer + global theme slider can be demoed
|
||||
without a real capture run. Run: python make_example.py"""
|
||||
import base64
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
|
||||
|
||||
def svg(bg, fg, panel, accent, muted, label, kind):
|
||||
"""A simple fake 'screen' SVG: title bar, sidebar, content varies by kind."""
|
||||
parts = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">',
|
||||
f'<rect width="1600" height="900" fill="{bg}"/>',
|
||||
# top bar
|
||||
f'<rect width="1600" height="64" fill="{panel}"/>',
|
||||
f'<circle cx="40" cy="32" r="12" fill="{accent}"/>',
|
||||
f'<rect x="64" y="24" width="160" height="16" rx="6" fill="{muted}"/>',
|
||||
f'<rect x="1430" y="20" width="130" height="24" rx="12" fill="{accent}"/>',
|
||||
# left sidebar
|
||||
f'<rect x="0" y="64" width="220" height="836" fill="{panel}"/>',
|
||||
]
|
||||
for i in range(6):
|
||||
y = 100 + i * 56
|
||||
parts.append(f'<rect x="24" y="{y}" width="172" height="32" rx="8" fill="{bg}"/>')
|
||||
if kind == "empty":
|
||||
parts += [
|
||||
f'<rect x="700" y="360" width="200" height="120" rx="16" fill="none" stroke="{muted}" stroke-width="3" stroke-dasharray="10 8"/>',
|
||||
f'<rect x="690" y="510" width="220" height="44" rx="10" fill="{accent}"/>',
|
||||
f'<text x="800" y="600" fill="{muted}" font-family="sans-serif" font-size="26" text-anchor="middle">{label}</text>',
|
||||
]
|
||||
elif kind == "form":
|
||||
for i in range(4):
|
||||
y = 140 + i * 90
|
||||
parts.append(f'<rect x="280" y="{y}" width="160" height="16" rx="6" fill="{muted}"/>')
|
||||
parts.append(f'<rect x="280" y="{y+26}" width="900" height="44" rx="8" fill="{panel}" stroke="{muted}" stroke-width="1"/>')
|
||||
parts.append(f'<rect x="280" y="560" width="200" height="50" rx="10" fill="{accent}"/>')
|
||||
parts.append(f'<text x="800" y="850" fill="{muted}" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>')
|
||||
else: # dialog
|
||||
parts += [
|
||||
f'<rect width="1600" height="900" fill="{fg}" opacity="0.45"/>',
|
||||
f'<rect x="520" y="280" width="560" height="360" rx="18" fill="{panel}"/>',
|
||||
f'<rect x="556" y="320" width="280" height="22" rx="8" fill="{fg}"/>',
|
||||
f'<rect x="556" y="372" width="488" height="14" rx="6" fill="{muted}"/>',
|
||||
f'<rect x="556" y="398" width="420" height="14" rx="6" fill="{muted}"/>',
|
||||
f'<rect x="820" y="560" width="110" height="44" rx="9" fill="{bg}" stroke="{muted}"/>',
|
||||
f'<rect x="946" y="560" width="98" height="44" rx="9" fill="{accent}"/>',
|
||||
f'<text x="800" y="700" fill="#fff" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>',
|
||||
]
|
||||
parts.append("</svg>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def data_uri(s):
|
||||
return "data:image/svg+xml;base64," + base64.b64encode(s.encode()).decode()
|
||||
|
||||
|
||||
LIGHT = dict(bg="#ffffff", fg="#111418", panel="#f1f3f6", accent="#2f6fed", muted="#c2c8d0")
|
||||
DARK = dict(bg="#16181c", fg="#000000", panel="#1f232a", accent="#5b8cff", muted="#3a414b")
|
||||
|
||||
|
||||
def pair(kind, label):
|
||||
return (
|
||||
data_uri(svg(LIGHT["bg"], LIGHT["fg"], LIGHT["panel"], LIGHT["accent"], LIGHT["muted"], label, kind)),
|
||||
data_uri(svg(DARK["bg"], DARK["fg"], DARK["panel"], DARK["accent"], DARK["muted"], label, kind)),
|
||||
)
|
||||
|
||||
|
||||
views = []
|
||||
for idx, (kind, title, label) in enumerate([
|
||||
("empty", "Empty state", "Drop a PDF to start"),
|
||||
("form", "Tool options panel", "Compress options"),
|
||||
("dialog", "Confirm dialog", "Replace original file?"),
|
||||
], start=1):
|
||||
light, dark = pair(kind, label)
|
||||
views.append({
|
||||
"id": f"{idx:02d}_{kind}",
|
||||
"title": title,
|
||||
"light": light,
|
||||
"dark": dark,
|
||||
"viewport": "1600x900",
|
||||
"notes": ["This is mock data to demo the viewer."],
|
||||
})
|
||||
|
||||
data = {
|
||||
"feature": "EXAMPLE - Compress PDF (mock data)",
|
||||
"branch": "demo",
|
||||
"generated": "example",
|
||||
"views": views,
|
||||
"findings": {
|
||||
"visual": [
|
||||
{"severity": "high", "view": "03_dialog", "title": "Dialog buttons too close",
|
||||
"detail": "Cancel/Confirm have only 8px gap; easy to misclick.",
|
||||
"fix": "Increase gap to var(--mantine-spacing-md)."},
|
||||
{"severity": "low", "view": "02_form", "title": "Field labels low contrast in dark mode",
|
||||
"detail": "Muted token fails WCAG AA on the dark panel.",
|
||||
"fix": "Use --mantine-color-dimmed instead of a hard-coded grey."},
|
||||
],
|
||||
"ux": [
|
||||
{"severity": "med", "view": "01_empty", "title": "Primary CTA below the dropzone",
|
||||
"detail": "Users expect the action button adjacent to the dropzone.",
|
||||
"fix": "Move the button directly under the dashed zone."},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
tpl = (HERE / "report-template.html").read_text(encoding="utf-8")
|
||||
out = re.sub(
|
||||
r"/\*__DATA__\*/.*?/\*__END__\*/",
|
||||
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
|
||||
tpl, count=1, flags=re.S,
|
||||
)
|
||||
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
|
||||
print("wrote", (HERE / "EXAMPLE.html"))
|
||||
@@ -0,0 +1,298 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
UI Walkthrough report template (self-contained, works from file://).
|
||||
The ui-walkthrough skill replaces the JSON in the window.__WALKTHROUGH__ data
|
||||
block below with the captured manifest. Do not add external CDN deps - it must open offline.
|
||||
|
||||
Data shape:
|
||||
{
|
||||
"feature": "Compress PDF tool",
|
||||
"branch": "claude/...",
|
||||
"generated": "2026-06-21",
|
||||
"views": [
|
||||
{ "id": "01_empty", "title": "Empty state",
|
||||
"light": "screenshots/compress/01_empty_light.png",
|
||||
"dark": "screenshots/compress/01_empty_dark.png",
|
||||
"viewport": "1600x900",
|
||||
"notes": ["Heading is centered", "Primary CTA below the fold on mobile"] }
|
||||
],
|
||||
"findings": {
|
||||
"visual": [ { "severity":"high", "view":"01_empty", "title":"...", "detail":"...", "fix":"..." } ],
|
||||
"ux": [ { "severity":"med", "view":"03_dialog", "title":"...", "detail":"...", "fix":"..." } ]
|
||||
}
|
||||
}
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>UI Walkthrough</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f7f9; --panel: #ffffff; --panel-2: #f0f2f5; --text: #1a1b1e;
|
||||
--muted: #6b7280; --border: #e2e5ea; --accent: #2f6fed; --accent-weak: #e8f0fe;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);
|
||||
--hi: #d92d20; --med: #d98e00; --low: #2f6fed; --stage: #0b0c0e;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
--bg: #0d0e10; --panel: #16181c; --panel-2: #1d2024; --text: #e6e8eb;
|
||||
--muted: #9aa3ad; --border: #2a2e35; --accent: #5b8cff; --accent-weak: #1a2336;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.5), 0 8px 24px rgba(0,0,0,.4); --stage: #000;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.5 -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
background: var(--bg); color: var(--text); }
|
||||
header { display: flex; align-items: center; gap: 16px; padding: 12px 20px;
|
||||
background: var(--panel); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 5; }
|
||||
header h1 { font-size: 15px; margin: 0; font-weight: 650; }
|
||||
header .sub { color: var(--muted); font-size: 12px; }
|
||||
.spacer { flex: 1; }
|
||||
.counter { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
|
||||
.tabs { display: flex; gap: 4px; }
|
||||
.tab { border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
|
||||
padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; }
|
||||
.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
|
||||
/* Light/Dark slider */
|
||||
.theme-toggle { display: flex; align-items: center; gap: 9px; user-select: none; }
|
||||
.theme-toggle .lbl { font-size: 12px; color: var(--muted); }
|
||||
.theme-toggle .lbl.on { color: var(--text); font-weight: 600; }
|
||||
.switch { position: relative; width: 52px; height: 28px; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; inset: 0; cursor: pointer; background: var(--panel-2);
|
||||
border: 1px solid var(--border); border-radius: 999px; transition: .2s; }
|
||||
.slider:before { content: ""; position: absolute; height: 20px; width: 20px; left: 3px; top: 3px;
|
||||
background: #fbbf24; border-radius: 50%; transition: .2s; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
|
||||
.switch input:checked + .slider { background: var(--accent); }
|
||||
.switch input:checked + .slider:before { transform: translateX(24px); background: #c7d2fe; }
|
||||
|
||||
main { display: grid; grid-template-columns: 240px 1fr; height: calc(100vh - 53px); }
|
||||
.rail { border-right: 1px solid var(--border); overflow-y: auto; background: var(--panel); padding: 8px; }
|
||||
.rail .group-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em;
|
||||
color: var(--muted); padding: 10px 8px 4px; }
|
||||
.thumb { display: flex; gap: 9px; align-items: center; padding: 7px; border-radius: 8px;
|
||||
cursor: pointer; border: 1px solid transparent; }
|
||||
.thumb:hover { background: var(--panel-2); }
|
||||
.thumb.active { background: var(--accent-weak); border-color: var(--accent); }
|
||||
.thumb img { width: 64px; height: 40px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); background: var(--stage); }
|
||||
.thumb .t { font-size: 12.5px; line-height: 1.3; }
|
||||
.thumb .badge { font-size: 10px; color: var(--muted); }
|
||||
.thumb .dot { width: 7px; height: 7px; border-radius: 50%; margin-left: auto; flex: none; }
|
||||
|
||||
.stagewrap { display: flex; flex-direction: column; min-width: 0; }
|
||||
.stage { flex: 1; display: flex; align-items: center; justify-content: center; padding: 22px;
|
||||
background: var(--stage); position: relative; min-height: 0; }
|
||||
.stage img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px;
|
||||
box-shadow: 0 4px 30px rgba(0,0,0,.4); background: #fff; }
|
||||
html[data-theme="dark"] .stage img { background: #16181c; }
|
||||
.nav-btn { position: absolute; top: 50%; transform: translateY(-50%); width: 42px; height: 42px;
|
||||
border-radius: 50%; border: 1px solid var(--border); background: var(--panel);
|
||||
color: var(--text); cursor: pointer; font-size: 18px; opacity: .85; }
|
||||
.nav-btn:hover { opacity: 1; } .nav-btn.prev { left: 16px; } .nav-btn.next { right: 16px; }
|
||||
.nav-btn:disabled { opacity: .25; cursor: default; }
|
||||
.missing { color: var(--muted); font-size: 13px; text-align: center; }
|
||||
|
||||
.detail { border-top: 1px solid var(--border); background: var(--panel); padding: 14px 20px;
|
||||
max-height: 38vh; overflow-y: auto; }
|
||||
.detail h2 { margin: 0 0 4px; font-size: 15px; }
|
||||
.detail .meta { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
|
||||
.notes { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
|
||||
.notes li { display: flex; gap: 8px; align-items: flex-start; }
|
||||
.sev { font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 2px 7px; border-radius: 999px;
|
||||
color: #fff; flex: none; margin-top: 1px; }
|
||||
.sev.high { background: var(--hi); } .sev.med { background: var(--med); } .sev.low { background: var(--low); }
|
||||
.finding .fix { color: var(--muted); font-size: 12.5px; }
|
||||
.finding .fix b { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* Summary tab */
|
||||
.summary { padding: 20px 28px; overflow-y: auto; }
|
||||
.summary h2 { font-size: 16px; margin: 22px 0 8px; }
|
||||
.summary .empty { color: var(--muted); }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 12px 14px; margin-bottom: 8px; box-shadow: var(--shadow); }
|
||||
.card .head { display: flex; gap: 8px; align-items: center; }
|
||||
.card a { color: var(--accent); text-decoration: none; cursor: pointer; }
|
||||
.hide { display: none !important; }
|
||||
kbd { font: 11px ui-monospace, monospace; background: var(--panel-2); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 1px 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1 id="feature-title">UI Walkthrough</h1>
|
||||
<div class="sub" id="feature-sub"></div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="viewer">Walkthrough</button>
|
||||
<button class="tab" data-tab="summary">Findings</button>
|
||||
</div>
|
||||
<div class="counter" id="counter"></div>
|
||||
<label class="theme-toggle" title="Toggle light / dark for every screenshot">
|
||||
<span class="lbl" id="lbl-light">Light</span>
|
||||
<span class="switch"><input type="checkbox" id="theme-switch" /><span class="slider"></span></span>
|
||||
<span class="lbl" id="lbl-dark">Dark</span>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<main id="viewer-pane">
|
||||
<aside class="rail" id="rail"></aside>
|
||||
<section class="stagewrap">
|
||||
<div class="stage">
|
||||
<button class="nav-btn prev" id="prev" aria-label="Previous">‹</button>
|
||||
<img id="stage-img" alt="" />
|
||||
<div class="missing hide" id="missing"></div>
|
||||
<button class="nav-btn next" id="next" aria-label="Next">›</button>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<h2 id="view-title"></h2>
|
||||
<div class="meta" id="view-meta"></div>
|
||||
<ul class="notes" id="view-notes"></ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section class="summary hide" id="summary-pane"></section>
|
||||
|
||||
<script id="data">
|
||||
window.__WALKTHROUGH__ = /*__DATA__*/{"feature":"No data","branch":"","generated":"","views":[],"findings":{"visual":[],"ux":[]}}/*__END__*/;
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var D = window.__WALKTHROUGH__ || { views: [], findings: { visual: [], ux: [] } };
|
||||
var views = D.views || [];
|
||||
var state = { i: 0, theme: localStorage.getItem("ui-wt-theme") || "light", tab: "viewer" };
|
||||
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
function sevClass(s) { return s === "high" ? "high" : s === "med" || s === "medium" ? "med" : "low"; }
|
||||
|
||||
function applyChrome() {
|
||||
document.documentElement.setAttribute("data-theme", state.theme);
|
||||
$("theme-switch").checked = state.theme === "dark";
|
||||
$("lbl-light").classList.toggle("on", state.theme === "light");
|
||||
$("lbl-dark").classList.toggle("on", state.theme === "dark");
|
||||
}
|
||||
|
||||
function srcFor(v) { return state.theme === "dark" ? (v.dark || v.light) : (v.light || v.dark); }
|
||||
|
||||
function findingsForView(id) {
|
||||
var all = (D.findings && D.findings.visual || []).concat(D.findings && D.findings.ux || []);
|
||||
return all.filter(function (f) { return f.view === id; });
|
||||
}
|
||||
|
||||
function renderRail() {
|
||||
var rail = $("rail");
|
||||
rail.innerHTML = "";
|
||||
if (!views.length) { rail.innerHTML = '<div class="group-label">No views captured</div>'; return; }
|
||||
views.forEach(function (v, idx) {
|
||||
var fs = findingsForView(v.id);
|
||||
var worst = fs.some(function (f){return sevClass(f.severity)==="high";}) ? "var(--hi)"
|
||||
: fs.some(function (f){return sevClass(f.severity)==="med";}) ? "var(--med)"
|
||||
: fs.length ? "var(--low)" : "transparent";
|
||||
var el = document.createElement("div");
|
||||
el.className = "thumb" + (idx === state.i ? " active" : "");
|
||||
el.innerHTML = '<img src="' + srcFor(v) + '" alt="" />' +
|
||||
'<div><div class="t">' + (v.title || v.id) + '</div>' +
|
||||
'<div class="badge">' + (v.viewport || "") + '</div></div>' +
|
||||
'<span class="dot" style="background:' + worst + '"></span>';
|
||||
el.onclick = function () { state.i = idx; render(); };
|
||||
rail.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
applyChrome();
|
||||
if (!views.length) {
|
||||
$("missing").classList.remove("hide"); $("stage-img").classList.add("hide");
|
||||
$("missing").textContent = "No screenshots in this report yet.";
|
||||
$("counter").textContent = ""; return;
|
||||
}
|
||||
var v = views[state.i];
|
||||
var src = srcFor(v);
|
||||
var img = $("stage-img");
|
||||
if (src) {
|
||||
img.classList.remove("hide"); $("missing").classList.add("hide");
|
||||
img.src = src; img.alt = v.title || v.id;
|
||||
} else {
|
||||
img.classList.add("hide"); $("missing").classList.remove("hide");
|
||||
$("missing").textContent = "No " + state.theme + " screenshot for this view.";
|
||||
}
|
||||
$("counter").textContent = (state.i + 1) + " / " + views.length;
|
||||
$("view-title").textContent = v.title || v.id;
|
||||
$("view-meta").textContent = [v.viewport, state.theme + " mode"].filter(Boolean).join(" · ");
|
||||
var notes = $("view-notes"); notes.innerHTML = "";
|
||||
var fs = findingsForView(v.id);
|
||||
(v.notes || []).forEach(function (n) {
|
||||
var li = document.createElement("li"); li.textContent = "· " + n; notes.appendChild(li);
|
||||
});
|
||||
fs.forEach(function (f) {
|
||||
var li = document.createElement("li"); li.className = "finding";
|
||||
li.innerHTML = '<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
|
||||
'<span><b>' + (f.title || "") + '</b> — ' + (f.detail || "") +
|
||||
(f.fix ? ' <span class="fix"><b>Fix:</b> ' + f.fix + '</span>' : '') + '</span>';
|
||||
notes.appendChild(li);
|
||||
});
|
||||
$("prev").disabled = state.i === 0;
|
||||
$("next").disabled = state.i === views.length - 1;
|
||||
renderRail();
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
var pane = $("summary-pane");
|
||||
function block(title, arr) {
|
||||
var h = '<h2>' + title + ' (' + arr.length + ')</h2>';
|
||||
if (!arr.length) return h + '<div class="empty">None found.</div>';
|
||||
return h + arr.map(function (f) {
|
||||
return '<div class="card"><div class="head">' +
|
||||
'<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
|
||||
'<b>' + (f.title || "") + '</b>' +
|
||||
(f.view ? ' <a data-jump="' + f.view + '">' + f.view + '</a>' : '') + '</div>' +
|
||||
'<div style="margin-top:6px">' + (f.detail || "") + '</div>' +
|
||||
(f.fix ? '<div class="finding" style="margin-top:6px"><span class="fix"><b>Fix:</b> ' + f.fix + '</span></div>' : '') +
|
||||
'</div>';
|
||||
}).join("");
|
||||
}
|
||||
pane.innerHTML = block("Visual & consistency", (D.findings && D.findings.visual) || []) +
|
||||
block("UX & ease of use", (D.findings && D.findings.ux) || []);
|
||||
pane.querySelectorAll("[data-jump]").forEach(function (a) {
|
||||
a.onclick = function () {
|
||||
var id = a.getAttribute("data-jump");
|
||||
var idx = views.findIndex(function (v) { return v.id === id; });
|
||||
if (idx >= 0) { state.i = idx; setTab("viewer"); }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function setTab(t) {
|
||||
state.tab = t;
|
||||
document.querySelectorAll(".tab").forEach(function (b) { b.classList.toggle("active", b.dataset.tab === t); });
|
||||
$("viewer-pane").classList.toggle("hide", t !== "viewer");
|
||||
$("summary-pane").classList.toggle("hide", t !== "summary");
|
||||
if (t === "viewer") $("viewer-pane").style.display = "grid";
|
||||
if (t === "summary") renderSummary();
|
||||
}
|
||||
|
||||
// wiring
|
||||
$("feature-title").textContent = D.feature || "UI Walkthrough";
|
||||
$("feature-sub").textContent = [D.branch, D.generated].filter(Boolean).join(" · ");
|
||||
$("theme-switch").onchange = function () {
|
||||
state.theme = this.checked ? "dark" : "light";
|
||||
localStorage.setItem("ui-wt-theme", state.theme);
|
||||
render();
|
||||
};
|
||||
$("prev").onclick = function () { if (state.i > 0) { state.i--; render(); } };
|
||||
$("next").onclick = function () { if (state.i < views.length - 1) { state.i++; render(); } };
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (state.tab !== "viewer") return;
|
||||
if (e.key === "ArrowLeft") $("prev").click();
|
||||
if (e.key === "ArrowRight") $("next").click();
|
||||
if (e.key.toLowerCase() === "t") $("theme-switch").click();
|
||||
});
|
||||
document.querySelectorAll(".tab").forEach(function (b) { b.onclick = function () { setTab(b.dataset.tab); }; });
|
||||
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.14.2
|
||||
pkgver=2.14.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.14.2
|
||||
pkgver=2.14.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -116,6 +116,9 @@ jobs:
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
# Single source of truth for whether this preview embeds the admin portal:
|
||||
# drives the image build-arg and the deployment comment.
|
||||
BUILD_PORTAL: "true"
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -246,7 +249,9 @@ jobs:
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push V2 image (Docker fork fallback)
|
||||
@@ -259,7 +264,9 @@ jobs:
|
||||
cache-from: type=gha,scope=stirling-pdf-latest
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
@@ -290,6 +297,8 @@ jobs:
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
POLICIES_ENABLED: "true"
|
||||
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
|
||||
@@ -359,12 +368,19 @@ jobs:
|
||||
}
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
|
||||
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
|
||||
|
||||
// Only mention the portal when this image actually embeds it.
|
||||
// Use the direct IP URL - the SSL hostname isn't supported yet.
|
||||
const withPortal = "${{ env.BUILD_PORTAL }}" === "true";
|
||||
const portalNote = withPortal
|
||||
? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n`
|
||||
: ``;
|
||||
|
||||
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
|
||||
`Your V2 PR with embedded architecture has been deployed!\n\n` +
|
||||
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
|
||||
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
|
||||
`🔐 **Secure HTTPS URL**: unsupported currently\n\n` +
|
||||
portalNote +
|
||||
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
|
||||
`🔄 **Auto-deployed** for approved V2 contributors.`;
|
||||
|
||||
|
||||
@@ -57,6 +57,15 @@ public class RequestUriUtils {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Admin portal SPA shell. Served publicly like the editor root so a direct
|
||||
// nav / refresh to /portal loads the app (the JWT lives in localStorage, not
|
||||
// a cookie, so the server can't authenticate the navigation itself). The
|
||||
// portal gates access via its own auth gate + RequirePortalAccess, and its
|
||||
// data APIs stay protected, so serving the shell pre-auth is safe.
|
||||
if (normalizedUri.equals("/portal") || normalizedUri.startsWith("/portal/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Treat common static file extensions as static resources
|
||||
return normalizedUri.endsWith(".svg")
|
||||
|| normalizedUri.endsWith(".png")
|
||||
|
||||
@@ -73,6 +73,14 @@ class RequestUriUtilsTest {
|
||||
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResource_portalShell() {
|
||||
// The admin portal SPA shell is served pre-auth so it's directly navigable.
|
||||
assertTrue(RequestUriUtils.isStaticResource("/portal"));
|
||||
assertTrue(RequestUriUtils.isStaticResource("/portal/users"));
|
||||
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/portal"));
|
||||
}
|
||||
|
||||
// --- isFrontendRoute tests ---
|
||||
|
||||
@Test
|
||||
|
||||
+11
-1
@@ -175,6 +175,14 @@ springBoot {
|
||||
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
|
||||
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
|
||||
def buildPrototypes = project.hasProperty('prototypesMode') && project.property('prototypesMode') == 'true'
|
||||
// The admin portal ships as a lazy route inside the editor bundle (see
|
||||
// proprietary/routes/adminRouteExtensions). -PbuildWithPortal=true includes that
|
||||
// chunk via VITE_INCLUDE_PORTAL on the editor build; the deploy GHA sets it when
|
||||
// the portal or AI layers change. Building the portal implies building the editor.
|
||||
def buildWithPortal = project.hasProperty('buildWithPortal') && project.property('buildWithPortal') == 'true'
|
||||
if (buildWithPortal) {
|
||||
buildWithFrontend = true
|
||||
}
|
||||
// Workspace root holds package.json and node_modules (shared across editor /
|
||||
// future portal). Editor-specific paths (src, public, dist, tauri) live one
|
||||
// level deeper under frontend/editor/.
|
||||
@@ -297,9 +305,11 @@ tasks.register('npmBuild', Exec) {
|
||||
// Override VITE_API_BASE_URL to use relative paths for production builds
|
||||
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
|
||||
environment 'VITE_API_BASE_URL', '/'
|
||||
// Include the admin portal's lazy route/chunk in the editor build when requested.
|
||||
environment 'VITE_INCLUDE_PORTAL', (buildWithPortal ? 'true' : 'false')
|
||||
|
||||
doFirst {
|
||||
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)"
|
||||
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ public class User implements UserDetails, Serializable {
|
||||
|
||||
@ElementCollection
|
||||
@MapKeyColumn(name = "setting_key")
|
||||
@Lob
|
||||
@Column(name = "setting_value", columnDefinition = "text")
|
||||
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@JsonIgnore
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.14.2'
|
||||
version = '2.14.1'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
|
||||
|
||||
@@ -42,6 +42,9 @@ COPY . .
|
||||
ARG PROTOTYPES_BUILD=false
|
||||
ARG STIRLING_FLAVOR=proprietary
|
||||
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
|
||||
# Embed the admin portal app at /portal. Set true by the deploy workflow when the
|
||||
# portal or AI layers change; defaults false so normal builds skip the extra app.
|
||||
ARG BUILD_PORTAL=false
|
||||
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
@@ -49,6 +52,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
|
||||
STIRLING_FLAVOR=${STIRLING_FLAVOR} \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PbuildWithPortal=${BUILD_PORTAL} \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-PprototypesMode=${PROTOTYPES_BUILD} \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
|
||||
@@ -40,12 +40,15 @@ RUN gradle dependencies --no-daemon || true
|
||||
|
||||
COPY . .
|
||||
|
||||
# Embed the admin portal app at /portal when the deploy workflow flags it.
|
||||
ARG BUILD_PORTAL=false
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
|
||||
DISABLE_ADDITIONAL_FEATURES=false \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PbuildWithPortal=${BUILD_PORTAL} \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
@@ -40,12 +40,15 @@ RUN ./gradlew dependencies --no-daemon || true
|
||||
COPY . .
|
||||
|
||||
# Build ultra-lite JAR with embedded frontend (minimal features).
|
||||
# Embed the admin portal app at /portal when the deploy workflow flags it.
|
||||
ARG BUILD_PORTAL=false
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
|
||||
DISABLE_ADDITIONAL_FEATURES=true \
|
||||
./gradlew clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PbuildWithPortal=${BUILD_PORTAL} \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
@@ -61,6 +61,11 @@ const config: StorybookConfig = {
|
||||
config.define = {
|
||||
...(config.define ?? {}),
|
||||
"import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"),
|
||||
// Keep the Supabase auth env empty so ensureSaasSupabase() is a no-op and
|
||||
// never replaces the mock SaaS client stubbed in preview.tsx.
|
||||
"import.meta.env.VITE_SUPABASE_URL": JSON.stringify(""),
|
||||
"import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY":
|
||||
JSON.stringify(""),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
|
||||
@@ -29,10 +29,10 @@ initialize({ onUnhandledRequest: "bypass" }, handlers);
|
||||
|
||||
// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment
|
||||
// method, wallet) clear the session check and reach the MSW handlers instead of
|
||||
// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally
|
||||
// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only
|
||||
// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via
|
||||
// .storybook/main.ts's viteFinal define, not a frontend/.env file.
|
||||
// failing with "No SaaS session". VITE_SUPABASE_URL/KEY are defined empty (see
|
||||
// .storybook/main.ts), so ensureSaasSupabase() is a no-op and never replaces this
|
||||
// client; only VITE_SAAS_API_URL (a mock origin MSW matches) is configured —
|
||||
// injected via .storybook/main.ts's viteFinal define, not a frontend/.env file.
|
||||
const saasStub = configureSupabase({
|
||||
url: "http://saas.mock",
|
||||
key: "storybook-anon-key",
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
# API base URL — use / for same-origin (default for web builds)
|
||||
VITE_API_BASE_URL=/
|
||||
|
||||
# Include the admin portal's lazy route/chunk in the build (set true by
|
||||
# -PbuildWithPortal in the JAR). Off by default; always on in dev.
|
||||
VITE_INCLUDE_PORTAL=false
|
||||
|
||||
# Google Drive integration
|
||||
VITE_GOOGLE_DRIVE_CLIENT_ID=
|
||||
VITE_GOOGLE_DRIVE_API_KEY=
|
||||
|
||||
@@ -18,13 +18,6 @@ VITE_EDITOR_URL=/
|
||||
# backend.
|
||||
VITE_PORTAL_MOCKS=
|
||||
|
||||
# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account
|
||||
# linking (both values are public). Set per deploy; absent -> the account-link
|
||||
# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase
|
||||
# project the local backend links against.
|
||||
VITE_SAAS_SUPABASE_URL=
|
||||
VITE_SAAS_SUPABASE_ANON_KEY=
|
||||
|
||||
# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for
|
||||
# ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the
|
||||
# admin's Supabase JWT. Distinct from the local backend (reached same-origin via
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling PDF",
|
||||
"mainBinaryName": "Stirling-PDF",
|
||||
"version": "2.14.2",
|
||||
"version": "2.14.1",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Trigger: bare square icon button that blends into either sidebar's header. */
|
||||
.app-switch-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-4);
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.app-switch-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.app-switch-icon {
|
||||
width: 1rem;
|
||||
height: 1.0625rem;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Dropdown } from "@app/ui";
|
||||
import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
|
||||
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
|
||||
import "@app/components/shared/AppSwitch.css";
|
||||
|
||||
export type AppSwitchTarget = "editor" | "processor";
|
||||
|
||||
function ChevronDownIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppSwitchProps {
|
||||
/** The app this switcher is rendered in (shown as active in the menu). */
|
||||
current: AppSwitchTarget;
|
||||
/** Resolved color scheme; picks the brand mark for the menu items. */
|
||||
theme: "light" | "dark";
|
||||
/** Invoked with the selected app; only called for apps other than `current`. */
|
||||
onSwitch: (app: AppSwitchTarget) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor ⇄ processor app switcher (chevron button → app menu). The editor
|
||||
* and portal sidebars render this same element so the two apps present one
|
||||
* identical switcher; each host supplies its own theme source and navigation.
|
||||
*/
|
||||
export function AppSwitch({
|
||||
current,
|
||||
theme,
|
||||
onSwitch,
|
||||
className,
|
||||
}: AppSwitchProps) {
|
||||
const { t } = useTranslation();
|
||||
const mark = theme === "dark" ? markDark : markLight;
|
||||
const apps: Array<{ id: AppSwitchTarget; label: string }> = [
|
||||
{
|
||||
id: "processor",
|
||||
label: t("portal.shell.sidebar.appProcessor", "Processor"),
|
||||
},
|
||||
{ id: "editor", label: t("portal.shell.sidebar.appEditor", "Editor") },
|
||||
];
|
||||
return (
|
||||
<Dropdown.Root align="end" className={className}>
|
||||
<Dropdown.Trigger>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="app-switch-btn"
|
||||
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="11rem">
|
||||
{apps.map((app) => (
|
||||
<Dropdown.Item
|
||||
key={app.id}
|
||||
active={current === app.id}
|
||||
onSelect={app.id === current ? undefined : () => onSwitch(app.id)}
|
||||
leading={<img className="app-switch-icon" src={mark} alt="" />}
|
||||
>
|
||||
{app.label}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Core stub for the sidebar app switcher. Builds that bundle the admin portal
|
||||
* (proprietary/saas) shadow this with a real switcher; core has no portal, so
|
||||
* there is nothing to switch to.
|
||||
*/
|
||||
export function AppSwitcher() {
|
||||
return null;
|
||||
}
|
||||
@@ -102,6 +102,18 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* App switcher (portal builds only) sits at the far end of the header row.
|
||||
The content-fade animation makes this span a stacking context, which would
|
||||
trap the menu's z-index below later sidebar rows — elevate the span so the
|
||||
open menu paints above them. */
|
||||
.file-sidebar-app-switch {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: var(--z-dropdown);
|
||||
}
|
||||
|
||||
/* ---- Search row ---- */
|
||||
.file-sidebar-search-row {
|
||||
display: flex;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { accountService } from "@app/services/accountService";
|
||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
@@ -679,6 +680,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
className="file-sidebar-brand-text sidebar-content-fade"
|
||||
/>
|
||||
)}
|
||||
{!collapsed && (
|
||||
// The header row itself toggles collapse; stop the switcher's
|
||||
// clicks and key presses from reaching it.
|
||||
<span
|
||||
className="file-sidebar-app-switch sidebar-content-fade"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AppSwitcher />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
+84
-2
@@ -1,5 +1,12 @@
|
||||
import { buildChangeMetadataFormData } from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||
import { ChangeMetadataParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
import {
|
||||
buildChangeMetadataFormData,
|
||||
changeMetadataToApiParams,
|
||||
changeMetadataFromApiParams,
|
||||
} from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||
import {
|
||||
ChangeMetadataParameters,
|
||||
defaultParameters,
|
||||
} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
import { TrappedStatus } from "@app/types/metadata";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
@@ -142,3 +149,78 @@ describe("buildChangeMetadataFormData", () => {
|
||||
expect(formData.get("allRequestParams[customValue1]")).toBe("Engineering");
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeMetadata mappers", () => {
|
||||
const fullApi: Parameters<typeof changeMetadataFromApiParams>[0] = {
|
||||
title: "Title",
|
||||
author: "Author",
|
||||
subject: "Subject",
|
||||
keywords: "a, b",
|
||||
creator: "Creator",
|
||||
producer: "Producer",
|
||||
creationDate: "2024/01/15 10:30:00",
|
||||
modificationDate: "2024/02/20 14:05:09",
|
||||
trapped: "True",
|
||||
deleteAll: false,
|
||||
allRequestParams: {
|
||||
customKey1: "Department",
|
||||
customValue1: "Engineering",
|
||||
customKey2: "Project",
|
||||
customValue2: "Falcon",
|
||||
},
|
||||
};
|
||||
|
||||
test("round-trips a full request through fromApiParams and back", () => {
|
||||
const roundTripped = changeMetadataToApiParams({
|
||||
...defaultParameters,
|
||||
...changeMetadataFromApiParams(fullApi),
|
||||
});
|
||||
|
||||
expect(roundTripped).toEqual(fullApi);
|
||||
});
|
||||
|
||||
test("reconstructs dates in local time and clears absent ones", () => {
|
||||
const params = changeMetadataFromApiParams(fullApi);
|
||||
expect(params.creationDate).toEqual(new Date(2024, 0, 15, 10, 30, 0));
|
||||
expect(params.modificationDate).toEqual(new Date(2024, 1, 20, 14, 5, 9));
|
||||
|
||||
const cleared = changeMetadataFromApiParams({
|
||||
creationDate: "",
|
||||
modificationDate: undefined,
|
||||
});
|
||||
expect(cleared.creationDate).toBeNull();
|
||||
expect(cleared.modificationDate).toBeNull();
|
||||
});
|
||||
|
||||
test.each([TrappedStatus.TRUE, TrappedStatus.FALSE, TrappedStatus.UNKNOWN])(
|
||||
"round-trips trapped=%s",
|
||||
(trapped) => {
|
||||
const api = changeMetadataToApiParams({ ...defaultParameters, trapped });
|
||||
expect(api.trapped).toBe(trapped);
|
||||
expect(changeMetadataFromApiParams(api).trapped).toBe(trapped);
|
||||
},
|
||||
);
|
||||
|
||||
test("falls back to the default for an unrecognised trapped value", () => {
|
||||
const params = changeMetadataFromApiParams({
|
||||
trapped: "Bogus",
|
||||
} as unknown as Parameters<typeof changeMetadataFromApiParams>[0]);
|
||||
expect(params.trapped).toBe(defaultParameters.trapped);
|
||||
});
|
||||
|
||||
test("reconstructs custom metadata, tolerating non-contiguous indices", () => {
|
||||
const params = changeMetadataFromApiParams({
|
||||
allRequestParams: {
|
||||
customKey1: "Department",
|
||||
customValue1: "Engineering",
|
||||
customKey3: "Project",
|
||||
customValue3: "Falcon",
|
||||
},
|
||||
});
|
||||
|
||||
expect(params.customMetadata).toEqual([
|
||||
{ key: "Department", value: "Engineering", id: "custom1" },
|
||||
{ key: "Project", value: "Falcon", id: "custom3" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+126
-46
@@ -3,13 +3,22 @@ import {
|
||||
useToolOperation,
|
||||
defineSingleFileTool,
|
||||
} from "@app/hooks/tools/shared/useToolOperation";
|
||||
import {
|
||||
objectToFormData,
|
||||
type ToolApiParams,
|
||||
type ToolEndpoint,
|
||||
} from "@app/hooks/tools/shared/toolApiMapping";
|
||||
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
|
||||
import { TrappedStatus, CustomMetadataEntry } from "@app/types/metadata";
|
||||
import {
|
||||
ChangeMetadataParameters,
|
||||
defaultParameters,
|
||||
} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
|
||||
// Helper function to format Date object to string
|
||||
const ENDPOINT = "/api/v1/misc/update-metadata" satisfies ToolEndpoint;
|
||||
type ChangeMetadataApiParams = ToolApiParams[typeof ENDPOINT];
|
||||
|
||||
// Backend date format (yyyy/MM/dd HH:mm:ss), in local time to mirror the parser.
|
||||
const formatDateForBackend = (date: Date | null): string => {
|
||||
if (!date) return "";
|
||||
const year = date.getFullYear();
|
||||
@@ -21,64 +30,135 @@ const formatDateForBackend = (date: Date | null): string => {
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
// Inverse of formatDateForBackend; returns null for empty or unparseable input.
|
||||
const parseDateFromBackend = (value: string | undefined): Date | null => {
|
||||
if (!value) return null;
|
||||
const match = /^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(
|
||||
value,
|
||||
);
|
||||
if (!match) return null;
|
||||
const [, year, month, day, hours, minutes, seconds] = match;
|
||||
return new Date(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hours),
|
||||
Number(minutes),
|
||||
Number(seconds),
|
||||
);
|
||||
};
|
||||
|
||||
// Custom metadata is carried in the request's allRequestParams map as paired
|
||||
// customKey<N>/customValue<N> entries; the backend rejoins them by the shared
|
||||
// index N (see MetadataController). Only entries with a non-blank key and value
|
||||
// are sent, and they are re-numbered from 1 so the indices stay contiguous.
|
||||
const buildCustomMetadataMap = (
|
||||
customMetadata: CustomMetadataEntry[],
|
||||
): Record<string, string> | undefined => {
|
||||
const validEntries = customMetadata.filter(
|
||||
(entry) => entry.key.trim() && entry.value.trim(),
|
||||
);
|
||||
if (validEntries.length === 0) return undefined;
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
validEntries.forEach((entry, index) => {
|
||||
const n = index + 1;
|
||||
map[`customKey${n}`] = entry.key.trim();
|
||||
map[`customValue${n}`] = entry.value.trim();
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
// Rebuild the UI's custom metadata list from the allRequestParams map by pairing
|
||||
// customKey<N>/customValue<N> on their shared index N. Mirrors the backend
|
||||
// (MetadataController), which pairs by index across all entries and does not
|
||||
// assume the indices are contiguous, so a non-contiguous stored map round-trips.
|
||||
const parseCustomMetadataMap = (
|
||||
allRequestParams: ChangeMetadataApiParams["allRequestParams"],
|
||||
): CustomMetadataEntry[] => {
|
||||
if (!allRequestParams) return [];
|
||||
return Object.keys(allRequestParams)
|
||||
.map((key) => /^customKey(\d+)$/.exec(key)?.[1])
|
||||
.filter((n): n is string => n !== undefined)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map((n) => ({
|
||||
key: allRequestParams[`customKey${n}`] ?? "",
|
||||
value: allRequestParams[`customValue${n}`] ?? "",
|
||||
id: `custom${n}`,
|
||||
}));
|
||||
};
|
||||
|
||||
// Map the backend's trapped string onto the UI enum, validating against the
|
||||
// actual enum values so an unrecognised value falls back to the default instead
|
||||
// of being force-cast.
|
||||
const parseTrapped = (value: string | undefined): TrappedStatus =>
|
||||
Object.values(TrappedStatus).find((status) => status === value) ??
|
||||
defaultParameters.trapped;
|
||||
|
||||
// Convert the tool's UI parameters into the update-metadata request body. The
|
||||
// return type is the generated backend model, so a spec change that renames or
|
||||
// drops a field breaks the build here.
|
||||
export const changeMetadataToApiParams = (
|
||||
parameters: ChangeMetadataParameters,
|
||||
): ChangeMetadataApiParams => ({
|
||||
title: parameters.title,
|
||||
author: parameters.author,
|
||||
subject: parameters.subject,
|
||||
keywords: parameters.keywords,
|
||||
creator: parameters.creator,
|
||||
producer: parameters.producer,
|
||||
creationDate: formatDateForBackend(parameters.creationDate),
|
||||
modificationDate: formatDateForBackend(parameters.modificationDate),
|
||||
trapped: parameters.trapped,
|
||||
deleteAll: parameters.deleteAll,
|
||||
allRequestParams: buildCustomMetadataMap(parameters.customMetadata),
|
||||
});
|
||||
|
||||
// Reconstruct the tool's UI parameters from an update-metadata request body, so
|
||||
// a stored or AI-authored step can be re-rendered in the settings UI.
|
||||
export const changeMetadataFromApiParams = (
|
||||
apiParams: ChangeMetadataApiParams,
|
||||
): Partial<ChangeMetadataParameters> => ({
|
||||
title: apiParams.title ?? defaultParameters.title,
|
||||
author: apiParams.author ?? defaultParameters.author,
|
||||
subject: apiParams.subject ?? defaultParameters.subject,
|
||||
keywords: apiParams.keywords ?? defaultParameters.keywords,
|
||||
creator: apiParams.creator ?? defaultParameters.creator,
|
||||
producer: apiParams.producer ?? defaultParameters.producer,
|
||||
creationDate: parseDateFromBackend(apiParams.creationDate),
|
||||
modificationDate: parseDateFromBackend(apiParams.modificationDate),
|
||||
trapped: parseTrapped(apiParams.trapped),
|
||||
deleteAll: apiParams.deleteAll ?? defaultParameters.deleteAll,
|
||||
customMetadata: parseCustomMetadataMap(apiParams.allRequestParams),
|
||||
});
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildChangeMetadataFormData = (
|
||||
parameters: ChangeMetadataParameters,
|
||||
file: File,
|
||||
): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
|
||||
// Standard metadata fields
|
||||
formData.append("title", parameters.title || "");
|
||||
formData.append("author", parameters.author || "");
|
||||
formData.append("subject", parameters.subject || "");
|
||||
formData.append("keywords", parameters.keywords || "");
|
||||
formData.append("creator", parameters.creator || "");
|
||||
formData.append("producer", parameters.producer || "");
|
||||
|
||||
// Date fields - convert Date objects to strings
|
||||
formData.append(
|
||||
"creationDate",
|
||||
formatDateForBackend(parameters.creationDate),
|
||||
);
|
||||
formData.append(
|
||||
"modificationDate",
|
||||
formatDateForBackend(parameters.modificationDate),
|
||||
);
|
||||
|
||||
// Trapped status
|
||||
formData.append("trapped", parameters.trapped || "");
|
||||
|
||||
// Delete all metadata flag
|
||||
formData.append("deleteAll", parameters.deleteAll.toString());
|
||||
|
||||
// Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]'
|
||||
let keyNumber = 0;
|
||||
if (parameters.customMetadata && Array.isArray(parameters.customMetadata)) {
|
||||
parameters.customMetadata.forEach((entry) => {
|
||||
if (entry.key.trim() && entry.value.trim()) {
|
||||
keyNumber += 1;
|
||||
formData.append(
|
||||
`allRequestParams[customKey${keyNumber}]`,
|
||||
entry.key.trim(),
|
||||
);
|
||||
formData.append(
|
||||
`allRequestParams[customValue${keyNumber}]`,
|
||||
entry.value.trim(),
|
||||
);
|
||||
}
|
||||
});
|
||||
// allRequestParams is a Spring-bound map: objectToFormData only serializes
|
||||
// primitives, so the scalar fields go through it and the map is flattened into
|
||||
// allRequestParams[<key>] form fields separately.
|
||||
const { allRequestParams, ...scalarParams } =
|
||||
changeMetadataToApiParams(parameters);
|
||||
const formData = objectToFormData(scalarParams, { fileInput: file });
|
||||
for (const [key, value] of Object.entries(allRequestParams ?? {})) {
|
||||
if (value !== undefined) {
|
||||
formData.append(`allRequestParams[${key}]`, value);
|
||||
}
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const changeMetadataOperationConfig = defineSingleFileTool({
|
||||
buildFormData: buildChangeMetadataFormData,
|
||||
toApiParams: changeMetadataToApiParams,
|
||||
fromApiParams: changeMetadataFromApiParams,
|
||||
operationType: "changeMetadata",
|
||||
endpoint: "/api/v1/misc/update-metadata",
|
||||
endpoint: ENDPOINT,
|
||||
defaultParameters,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { type RegistryToolOperationConfig } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import {
|
||||
ToolType,
|
||||
type RegistryToolOperationConfig,
|
||||
} from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import { objectToFormData } from "@app/hooks/tools/shared/toolApiMapping";
|
||||
|
||||
// Pilot tools.
|
||||
@@ -17,6 +20,7 @@ import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale
|
||||
import { autoRenameOperationConfig } from "@app/hooks/tools/autoRename/useAutoRenameOperation";
|
||||
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
|
||||
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
|
||||
import { changeMetadataOperationConfig } from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||
import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||
import { cropOperationConfig } from "@app/hooks/tools/crop/useCropOperation";
|
||||
import { editTableOfContentsOperationConfig } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation";
|
||||
@@ -57,6 +61,7 @@ const MIGRATED_CONFIGS = [
|
||||
autoRenameOperationConfig,
|
||||
bookletImpositionOperationConfig,
|
||||
certSignOperationConfig,
|
||||
changeMetadataOperationConfig,
|
||||
changePermissionsOperationConfig,
|
||||
cropOperationConfig,
|
||||
editTableOfContentsOperationConfig,
|
||||
@@ -102,15 +107,26 @@ describe("migrated tool mappers (sweep)", () => {
|
||||
expect(config.toApiParams).toBeDefined();
|
||||
expect(config.fromApiParams).toBeDefined();
|
||||
|
||||
// toApiParams(defaults) must produce a body objectToFormData can serialize
|
||||
// (i.e. only primitives / arrays of primitives). A mapper that leaked a
|
||||
// structured value would throw here.
|
||||
// Serialize the defaults through the tool's own buildFormData - the real
|
||||
// path the executor uses - so a tool whose toApiParams carries a structured
|
||||
// field that buildFormData flattens itself (e.g. changeMetadata's
|
||||
// allRequestParams map) is exercised too, not just tools whose mapper
|
||||
// output is directly objectToFormData-able. Custom tools have no
|
||||
// buildFormData, so fall back to serializing the mapper output directly.
|
||||
const params =
|
||||
config.defaultParameters ?? FALLBACK_PARAMS[config.operationType] ?? {};
|
||||
const apiParams = config.toApiParams!(params);
|
||||
expect(() =>
|
||||
objectToFormData(apiParams, { fileInput: file }),
|
||||
).not.toThrow();
|
||||
if (config.toolType === ToolType.multiFile) {
|
||||
const build = config.buildFormData;
|
||||
expect(() => build(params, [file])).not.toThrow();
|
||||
} else if (config.toolType === ToolType.singleFile) {
|
||||
const build = config.buildFormData;
|
||||
expect(() => build(params, file)).not.toThrow();
|
||||
} else {
|
||||
const toApiParams = config.toApiParams!;
|
||||
expect(() =>
|
||||
objectToFormData(toApiParams(params), { fileInput: file }),
|
||||
).not.toThrow();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Base path where the admin portal route-set mounts inside the editor app
|
||||
* (see adminRouteExtensions). Lives in core so any layer can reference the
|
||||
* mount point without importing portal code — build flavors that ship no
|
||||
* portal (core, desktop, prototypes) must never resolve @portal.
|
||||
*/
|
||||
export const PORTAL_BASENAME = "/portal";
|
||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: "2.14.2",
|
||||
appVersion: "2.14.1",
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
border-radius: var(--radius-sm);
|
||||
/* Explicit button reset: hosts without a global button reset (e.g. the
|
||||
editor) would otherwise show the UA's buttonface background and border. */
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Desktop inherits proprietary's layers but does not ship the portal (see
|
||||
* desktop/routes/adminRouteExtensions), so shadow the switcher back to empty —
|
||||
* otherwise the desktop bundle would reference @portal via the proprietary
|
||||
* switcher's imports.
|
||||
*/
|
||||
export function AppSwitcher() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TierProvider } from "@portal/contexts/TierContext";
|
||||
import { UIProvider } from "@portal/contexts/UIContext";
|
||||
import { PortalChrome } from "@portal/components/PortalChrome";
|
||||
|
||||
/**
|
||||
* SaaS provider stack. There is no account-link layer: the signed-in account IS
|
||||
* the SaaS account (auth is handled upstream by PortalAuthBoundary) and the tier
|
||||
* comes from the wallet (see portal-saas/contexts/usePlanTier). Dropping
|
||||
* LinkProvider / AccountLinkProvider / the login modal here keeps the link
|
||||
* machinery out of the SaaS bundle entirely.
|
||||
*/
|
||||
export function PortalProviders() {
|
||||
return (
|
||||
<TierProvider initialTier="pro">
|
||||
<UIProvider>
|
||||
<PortalChrome />
|
||||
</UIProvider>
|
||||
</TierProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { saasApiBase } from "@portal/api/saasApiBase";
|
||||
|
||||
describe("saasApiBase — SaaS build (one backend, VITE_API_BASE_URL)", () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it("reuses the editor's single backend base", () => {
|
||||
vi.stubEnv("VITE_API_BASE_URL", "http://localhost:8080");
|
||||
expect(saasApiBase()).toBe("http://localhost:8080");
|
||||
});
|
||||
|
||||
it("trims a trailing slash", () => {
|
||||
vi.stubEnv("VITE_API_BASE_URL", "https://app.stirling.com/");
|
||||
expect(saasApiBase()).toBe("https://app.stirling.com");
|
||||
});
|
||||
|
||||
it("maps same-origin '/' to '' — a valid (non-null) base, never 'unconfigured'", () => {
|
||||
vi.stubEnv("VITE_API_BASE_URL", "/");
|
||||
expect(saasApiBase()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* SaaS build: everything is the SaaS backend, so portal→SaaS reads target the
|
||||
* SAME base URL the editor uses ({@code VITE_API_BASE_URL}) rather than a separate
|
||||
* {@code VITE_SAAS_API_URL}. Default {@code "/"} → same-origin (the dev proxy /
|
||||
* deployment forwards to the SaaS backend); the Supabase JWT is the one credential.
|
||||
*
|
||||
* <p>Never null — same-origin is always a valid base — so {@code apiClient.saas}
|
||||
* never enters the self-hosted "SaaS not configured" state.
|
||||
*/
|
||||
export function saasApiBase(): string {
|
||||
const raw = import.meta.env.VITE_API_BASE_URL ?? "/";
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { allowConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
// Controllable auth state for the mocked provider.
|
||||
const authState: { session: unknown; loading: boolean } = {
|
||||
session: null,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
vi.mock("@app/auth", () => ({
|
||||
// Passthrough — we drive gating via the mocked useAuth below.
|
||||
AuthProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@app/auth/context", () => ({ useAuth: () => authState }));
|
||||
vi.mock("@app/ui", () => ({ Spinner: () => null }));
|
||||
vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() }));
|
||||
|
||||
import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary";
|
||||
|
||||
describe("PortalAuthBoundary — SaaS", () => {
|
||||
beforeEach(() => {
|
||||
authState.session = null;
|
||||
authState.loading = false;
|
||||
});
|
||||
|
||||
it("renders the portal when a Supabase session is present (no login)", () => {
|
||||
authState.session = { user: { id: "u1" }, access_token: "tok" };
|
||||
render(
|
||||
<PortalAuthBoundary>
|
||||
<div data-testid="portal">PORTAL</div>
|
||||
</PortalAuthBoundary>,
|
||||
);
|
||||
expect(screen.getByTestId("portal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gates (does not render the portal) when there is no session", () => {
|
||||
authState.session = null;
|
||||
// The gate bounces to /login; jsdom doesn't implement navigation, so absorb
|
||||
// that incidental warning rather than fail the console guard.
|
||||
allowConsole.error(/not implemented|navigation/i);
|
||||
render(
|
||||
<PortalAuthBoundary>
|
||||
<div data-testid="portal">PORTAL</div>
|
||||
</PortalAuthBoundary>,
|
||||
);
|
||||
expect(screen.queryByTestId("portal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gates while the session is still resolving", () => {
|
||||
authState.loading = true;
|
||||
render(
|
||||
<PortalAuthBoundary>
|
||||
<div data-testid="portal">PORTAL</div>
|
||||
</PortalAuthBoundary>,
|
||||
);
|
||||
expect(screen.queryByTestId("portal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { AuthProvider } from "@app/auth";
|
||||
import { useAuth } from "@app/auth/context";
|
||||
import { Spinner } from "@app/ui";
|
||||
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
|
||||
|
||||
function FullScreen({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100dvh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SaaS gate: viewing your own usage is not admin-gated, so require only a session
|
||||
* (not portalAccess). No session → bounce to the editor's Supabase login, which
|
||||
* returns here signed in. This is deliberately laxer than the self-hosted
|
||||
* RequirePortalAccess admin gate.
|
||||
*/
|
||||
function SaasPortalGate({ children }: { children: ReactNode }) {
|
||||
const { session, loading } = useAuth();
|
||||
useEffect(() => {
|
||||
if (!loading && !session) {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}, [loading, session]);
|
||||
if (loading || !session) {
|
||||
return (
|
||||
<FullScreen>
|
||||
<Spinner size="lg" />
|
||||
</FullScreen>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SaaS override of the portal auth boundary: authenticate against the SaaS Supabase
|
||||
* project (inheriting the editor's session) instead of the self-hosted Spring login.
|
||||
*/
|
||||
export function PortalAuthBoundary({ children }: { children: ReactNode }) {
|
||||
// Configure the shared Supabase client (SaaS project) synchronously here, before
|
||||
// the AuthProvider below reads it — a useEffect would run too late for the first
|
||||
// render, leaving the provider with a null client. Idempotent; the portal mounts
|
||||
// outside the editor's AppProviders but against the SAME project, so a user already
|
||||
// signed into the editor is picked up from the persisted session (no second login).
|
||||
ensureSaasSupabase();
|
||||
return (
|
||||
<AuthProvider mode="supabase">
|
||||
<SaasPortalGate>{children}</SaasPortalGate>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
|
||||
|
||||
describe("LinkAccountFooterItem (SaaS)", () => {
|
||||
it("renders nothing — SaaS has no account to link", () => {
|
||||
const { container } = render(<LinkAccountFooterItem />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* SaaS has no account-link concept — the signed-in account IS the SaaS account,
|
||||
* so there is nothing to link and no footer CTA to show.
|
||||
*/
|
||||
export function LinkAccountFooterItem() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("@portal/views/Usage", () => ({
|
||||
Usage: () => <div data-testid="usage" />,
|
||||
}));
|
||||
|
||||
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
|
||||
|
||||
describe("PortalBillingGate — SaaS", () => {
|
||||
it("renders the Usage page directly, with no link concept", () => {
|
||||
render(<PortalBillingGate />);
|
||||
expect(screen.getByTestId("usage")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Usage } from "@portal/views/Usage";
|
||||
|
||||
/**
|
||||
* SaaS billing gate: there is no link concept — the signed-in account IS the SaaS
|
||||
* account (auth is handled upstream by PortalAuthBoundary), so render the Usage
|
||||
* page directly. No link state, no prompt.
|
||||
*
|
||||
* onReauth sends the user back to the editor's Supabase login if the session
|
||||
* lapses mid-view: PortalAuthBoundary only re-gates on mount / session change, so
|
||||
* without this a "Session expired" notice would dead-end until a manual reload.
|
||||
*/
|
||||
export function PortalBillingGate() {
|
||||
return (
|
||||
<Usage
|
||||
onReauth={() => {
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings";
|
||||
|
||||
describe("accountLinkSettings (SaaS)", () => {
|
||||
it("is null — Settings has no account-link section on SaaS", () => {
|
||||
expect(accountLinkSettings).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { AccountLinkSettingsSeam } from "@portal-proprietary/components/settings/accountLinkSettings";
|
||||
|
||||
/**
|
||||
* SaaS has no account-link concept — the signed-in account IS the SaaS account.
|
||||
* Null drops the "Account link" nav item and its panel from Settings (the shared
|
||||
* SettingsModal treats the seam as optional), so the link-only AccountLinkPanel
|
||||
* is never imported into the SaaS bundle.
|
||||
*/
|
||||
export const accountLinkSettings: AccountLinkSettingsSeam | null = null;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
|
||||
const fetchWallet = vi.fn();
|
||||
vi.mock("@portal/api/billing", () => ({
|
||||
fetchWallet: () => fetchWallet(),
|
||||
}));
|
||||
|
||||
// Resolves to the SaaS override (src/portal-saas/contexts) via the @portal cascade.
|
||||
import { usePlanTier } from "@portal/contexts/usePlanTier";
|
||||
|
||||
function Probe() {
|
||||
return <span data-testid="tier">{usePlanTier()}</span>;
|
||||
}
|
||||
|
||||
describe("usePlanTier (SaaS) — tier from wallet", () => {
|
||||
beforeEach(() => {
|
||||
fetchWallet.mockReset();
|
||||
});
|
||||
|
||||
it("subscribed wallet → pro", async () => {
|
||||
fetchWallet.mockResolvedValue({ status: "subscribed" });
|
||||
const { getByTestId } = render(<Probe />);
|
||||
await waitFor(() => expect(getByTestId("tier").textContent).toBe("pro"));
|
||||
});
|
||||
|
||||
it("free wallet → free (also the loading default)", async () => {
|
||||
fetchWallet.mockResolvedValue({ status: "free" });
|
||||
const { getByTestId } = render(<Probe />);
|
||||
// Free before the fetch resolves and after — it never flips to pro.
|
||||
expect(getByTestId("tier").textContent).toBe("free");
|
||||
await waitFor(() => expect(fetchWallet).toHaveBeenCalledTimes(1));
|
||||
expect(getByTestId("tier").textContent).toBe("free");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { fetchWallet } from "@portal/api/billing";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
/**
|
||||
* The plan tier the portal runs on, SaaS flavor: the signed-in account IS the
|
||||
* SaaS account, so the tier comes straight from the wallet — no link concept.
|
||||
* `subscribed` → the metered Processor tier; anything else (including while the
|
||||
* wallet is still loading) → free. Enterprise stays a mocks-only tier.
|
||||
*/
|
||||
export function usePlanTier(): Tier {
|
||||
const { data: wallet } = useAsync(() => fetchWallet(), []);
|
||||
return wallet?.status === "subscribed" ? "pro" : "free";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
// Resolves to the SaaS override (src/portal-saas/contexts) via the @portal cascade.
|
||||
import { usePortalLinked } from "@portal/contexts/usePortalLinked";
|
||||
|
||||
function Probe() {
|
||||
return <span data-testid="linked">{String(usePortalLinked())}</span>;
|
||||
}
|
||||
|
||||
describe("usePortalLinked (SaaS)", () => {
|
||||
it("is always true — no account-link step, no LinkProvider needed", () => {
|
||||
// Renders with no LinkProvider in the tree: the SaaS override must not read it.
|
||||
const { getByTestId } = render(<Probe />);
|
||||
expect(getByTestId("linked").textContent).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* SaaS flavor: the signed-in account IS the SaaS account, so the portal is always
|
||||
* authorized — there is no account-link step. Procurement/checkout treats this as
|
||||
* "linked" and proceeds straight to the flow. No LinkContext dependency, so the
|
||||
* link machinery stays out of the SaaS bundle.
|
||||
*/
|
||||
export function usePortalLinked(): boolean {
|
||||
return true;
|
||||
}
|
||||
@@ -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` |
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { AuthProvider } from "@app/auth";
|
||||
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
|
||||
import { type ReactNode } from "react";
|
||||
import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary";
|
||||
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { TierProvider } from "@portal/contexts/TierContext";
|
||||
import { LinkProvider, useLink } from "@portal/contexts/LinkContext";
|
||||
import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin";
|
||||
import { UIProvider, useUI } from "@portal/contexts/UIContext";
|
||||
import { SuiProvider } from "@portal/theme/SuiProvider";
|
||||
import { AppShell } from "@portal/components/AppShell";
|
||||
import { AuthGate } from "@portal/components/AuthGate";
|
||||
import { AssistantButton } from "@portal/components/AssistantButton";
|
||||
import { AssistantPanel } from "@portal/components/AssistantPanel";
|
||||
import { SearchModal } from "@portal/components/SearchModal";
|
||||
import { SettingsModal } from "@portal/components/SettingsModal";
|
||||
import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal";
|
||||
import {
|
||||
AccountLinkProvider,
|
||||
useAccountLinkContext,
|
||||
} from "@portal/contexts/AccountLinkContext";
|
||||
import { ViewRouter } from "@portal/ViewRouter";
|
||||
import { PortalProviders } from "@portal/PortalProviders";
|
||||
// Reset + typography, scoped to .portal-scope below.
|
||||
import "@portal/theme/base.css";
|
||||
|
||||
@@ -33,93 +16,15 @@ function ThemedSuiProvider({ children }: { children: ReactNode }) {
|
||||
return <SuiProvider colorScheme={theme}>{children}</SuiProvider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global keyboard shortcuts. Lives below the UIProvider so it can dispatch
|
||||
* into the overlay state. Currently just ⌘K / Ctrl+K to toggle the search
|
||||
* palette.
|
||||
*/
|
||||
function GlobalShortcuts() {
|
||||
const { toggleSearch, closeSearch } = useUI();
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
|
||||
if (isCmdK) {
|
||||
e.preventDefault();
|
||||
toggleSearch();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
closeSearch();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [toggleSearch, closeSearch]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Bridges the Settings modal's open/close props to UIContext state. */
|
||||
function SettingsHost() {
|
||||
const { settingsOpen, settingsInitialSection, closeSettings } = useUI();
|
||||
return (
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={closeSettings}
|
||||
initialSection={settingsInitialSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one and only account-link login modal. Mounted at the app root (never
|
||||
* nested in another overlay) and driven by UIContext, so any "Link account" CTA
|
||||
* — sidebar, billing prompt, feature gate, Settings panel — opens this exact
|
||||
* instance. Linking is finished by the shared {@link useAccountLinkContext}
|
||||
* orchestration.
|
||||
*/
|
||||
function LinkModalHost() {
|
||||
const { linkModalOpen, linkModalMode, closeLinkModal } = useUI();
|
||||
const { markSaasSessionChanged } = useLink();
|
||||
const link = useAccountLinkContext();
|
||||
// "reauth" only refreshes the browser SaaS session for attended reads — the
|
||||
// sign-in already applied it to the Supabase client, so we just signal a
|
||||
// refetch. It must NOT call completeLink (that re-registers → duplicate row).
|
||||
const onLinked =
|
||||
linkModalMode === "reauth"
|
||||
? () => markSaasSessionChanged()
|
||||
: (session: SupabaseLoginSession) => link.completeLink(session);
|
||||
return (
|
||||
<LinkAccountModal
|
||||
open={linkModalOpen}
|
||||
mode={linkModalMode}
|
||||
onClose={closeLinkModal}
|
||||
onLinked={onLinked}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The routed view, wrapped in an error boundary so a single view crashing can't
|
||||
* white-screen the portal (the shell + nav stay alive). Keyed by route so
|
||||
* navigating to another section clears any error from the previous one.
|
||||
*/
|
||||
function RoutedContent() {
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
<ErrorBoundary key={pathname}>
|
||||
<ViewRouter />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The portal, mounted as a route-set under /portal/* inside the editor app (via
|
||||
* the admin-route seam). It supplies its own providers and its own i18next
|
||||
* instance (the `portal` namespace), but NOT a router — the editor's
|
||||
* <BrowserRouter> is the one and only router; the portal's routes are relative
|
||||
* to the /portal mount (see ViewRouter).
|
||||
*
|
||||
* The provider stack itself is a per-flavor seam (see {@link PortalProviders}):
|
||||
* self-hosted mounts the account-link layer, SaaS does not.
|
||||
*/
|
||||
export function PortalApp() {
|
||||
return (
|
||||
@@ -127,29 +32,9 @@ export function PortalApp() {
|
||||
<ThemedSuiProvider>
|
||||
{/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
|
||||
<div className="portal-scope">
|
||||
<AuthProvider mode="spring">
|
||||
<LinkProvider initialState="unlinked">
|
||||
{/* TierProvider sits INSIDE LinkProvider so it can derive the tier
|
||||
from the real link/subscription state when MSW mocks are off. */}
|
||||
<TierProvider initialTier="pro">
|
||||
<UIProvider>
|
||||
<GlobalShortcuts />
|
||||
<AuthGate>
|
||||
<AccountLinkProvider>
|
||||
<AppShell>
|
||||
<RoutedContent />
|
||||
</AppShell>
|
||||
<AssistantButton />
|
||||
<AssistantPanel />
|
||||
<SearchModal />
|
||||
<SettingsHost />
|
||||
<LinkModalHost />
|
||||
</AccountLinkProvider>
|
||||
</AuthGate>
|
||||
</UIProvider>
|
||||
</TierProvider>
|
||||
</LinkProvider>
|
||||
</AuthProvider>
|
||||
<PortalAuthBoundary>
|
||||
<PortalProviders />
|
||||
</PortalAuthBoundary>
|
||||
</div>
|
||||
</ThemedSuiProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { TierProvider } from "@portal/contexts/TierContext";
|
||||
import { LinkProvider, useLink } from "@portal/contexts/LinkContext";
|
||||
import { UIProvider, useUI } from "@portal/contexts/UIContext";
|
||||
import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin";
|
||||
import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal";
|
||||
import {
|
||||
AccountLinkProvider,
|
||||
useAccountLinkContext,
|
||||
} from "@portal/contexts/AccountLinkContext";
|
||||
import { PortalChrome } from "@portal/components/PortalChrome";
|
||||
|
||||
/**
|
||||
* The one and only account-link login modal. Mounted at the app root (never
|
||||
* nested in another overlay) and driven by UIContext, so any "Link account" CTA
|
||||
* — sidebar, billing prompt, feature gate, Settings panel — opens this exact
|
||||
* instance. Linking is finished by the shared {@link useAccountLinkContext}
|
||||
* orchestration.
|
||||
*/
|
||||
function LinkModalHost() {
|
||||
const { linkModalOpen, linkModalMode, closeLinkModal } = useUI();
|
||||
const { markSaasSessionChanged } = useLink();
|
||||
const link = useAccountLinkContext();
|
||||
// "reauth" only refreshes the browser SaaS session for attended reads — the
|
||||
// sign-in already applied it to the Supabase client, so we just signal a
|
||||
// refetch. It must NOT call completeLink (that re-registers → duplicate row).
|
||||
const onLinked =
|
||||
linkModalMode === "reauth"
|
||||
? () => markSaasSessionChanged()
|
||||
: (session: SupabaseLoginSession) => link.completeLink(session);
|
||||
return (
|
||||
<LinkAccountModal
|
||||
open={linkModalOpen}
|
||||
mode={linkModalMode}
|
||||
onClose={closeLinkModal}
|
||||
onLinked={onLinked}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-hosted provider stack. The account-link layer (LinkProvider +
|
||||
* AccountLinkProvider + the login modal) wraps the shared chrome; the tier is
|
||||
* derived from the link/subscription state (see usePlanTier). TierProvider sits
|
||||
* inside LinkProvider because the self-hosted usePlanTier reads useLink.
|
||||
*
|
||||
* The SaaS build shadows this file to drop the account-link layer entirely — the
|
||||
* signed-in account IS the SaaS account, so there is nothing to link and the
|
||||
* tier comes from the wallet.
|
||||
*/
|
||||
export function PortalProviders() {
|
||||
return (
|
||||
<LinkProvider initialState="unlinked">
|
||||
<TierProvider initialTier="pro">
|
||||
<UIProvider>
|
||||
<AccountLinkProvider>
|
||||
<PortalChrome />
|
||||
<LinkModalHost />
|
||||
</AccountLinkProvider>
|
||||
</UIProvider>
|
||||
</TierProvider>
|
||||
</LinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { Policies } from "@portal/views/Policies";
|
||||
import { Components } from "@portal/views/Components";
|
||||
import { EditorAdmin } from "@portal/views/EditorAdmin";
|
||||
import { Infrastructure } from "@portal/views/Infrastructure";
|
||||
import { Usage } from "@portal/views/Usage";
|
||||
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
|
||||
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
|
||||
import { Procurement } from "@portal/views/Procurement";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
@@ -39,7 +39,7 @@ export function ViewRouter() {
|
||||
path={rel(VIEW_PATHS.infrastructure)}
|
||||
element={<Infrastructure />}
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.usage)} element={<Usage />} />
|
||||
<Route path={rel(VIEW_PATHS.usage)} element={<PortalBillingGate />} />
|
||||
<Route path={rel(VIEW_PATHS.procurement)} element={<Procurement />} />
|
||||
<Route path={rel(VIEW_PATHS.docs)} element={<DeveloperDocs />} />
|
||||
{/* Account-link is now a Settings panel; redirect legacy bookmarks home. */}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -40,14 +40,17 @@
|
||||
* admin and uses the Supabase JWT for SaaS reads. Don't add it here.
|
||||
*/
|
||||
import { clearStoredToken, getStoredToken } from "@app/auth";
|
||||
import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
|
||||
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
|
||||
import { getPortalSaasToken } from "@portal/auth/portalSaasSession";
|
||||
import { saasApiBase } from "@portal/api/saasApiBase";
|
||||
|
||||
/** Read the SaaS base URL at call time so tests can stub it via vi.stubEnv. */
|
||||
/**
|
||||
* SaaS base URL via the flavor seam: self-hosted reads VITE_SAAS_API_URL (a
|
||||
* separate cloud backend); the SaaS build reuses the editor's single
|
||||
* VITE_API_BASE_URL (in SaaS everything is the SaaS backend). {@code null} means
|
||||
* not configured — a self-hosted-only state; the SaaS seam never returns null.
|
||||
*/
|
||||
function saasBaseUrl(): string | null {
|
||||
const raw = import.meta.env.VITE_SAAS_API_URL;
|
||||
if (!raw) return null;
|
||||
return raw.replace(/\/+$/, "");
|
||||
return saasApiBase();
|
||||
}
|
||||
|
||||
export interface HttpRequestOptions {
|
||||
@@ -169,21 +172,14 @@ async function localJson<T>(
|
||||
// saas — hosted SaaS Java, admin's Supabase JWT
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getSaasAccessToken(): Promise<string | null> {
|
||||
ensureSaasSupabase();
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) return null;
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data.session?.access_token ?? null;
|
||||
}
|
||||
|
||||
async function saasJson<T>(
|
||||
path: string,
|
||||
options: HttpRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const base = saasBaseUrl();
|
||||
if (!base) throw new SaasUnconfiguredError();
|
||||
const token = await getSaasAccessToken();
|
||||
// null = unset (self-hosted, no VITE_SAAS_API_URL). "" is same-origin (SaaS) — valid.
|
||||
if (base === null) throw new SaasUnconfiguredError();
|
||||
const token = await getPortalSaasToken();
|
||||
if (!token) throw new SaasNotLinkedError();
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method: options.method ?? "GET",
|
||||
@@ -213,7 +209,7 @@ export const apiClient = {
|
||||
/** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */
|
||||
saas: {
|
||||
json: saasJson,
|
||||
/** True when VITE_SAAS_API_URL is set. Doesn't check session liveness. */
|
||||
isConfigured: (): boolean => Boolean(saasBaseUrl()),
|
||||
/** True when a SaaS base URL is resolvable. Doesn't check session liveness. */
|
||||
isConfigured: (): boolean => saasBaseUrl() !== null,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Base URL for attended portal→SaaS reads ({@code apiClient.saas}) — the seam the
|
||||
* SaaS build overrides.
|
||||
*
|
||||
* <p>Self-hosted (this base): the SaaS cloud is a <em>separate</em> backend from
|
||||
* this instance's local one, configured via {@code VITE_SAAS_API_URL}. Returns
|
||||
* {@code null} when unset so {@code apiClient.saas} can surface a clear
|
||||
* "configure" state. A set value has any trailing slash trimmed.
|
||||
*
|
||||
* <p>The SaaS build shadows this to reuse the editor's single backend
|
||||
* ({@code VITE_API_BASE_URL}) — in SaaS everything is the SaaS backend, so there
|
||||
* is no separate SaaS URL.
|
||||
*/
|
||||
export function saasApiBase(): string | null {
|
||||
const raw = import.meta.env.VITE_SAAS_API_URL;
|
||||
if (!raw) return null;
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { AuthProvider } from "@app/auth";
|
||||
import { AuthGate } from "@portal/components/AuthGate";
|
||||
|
||||
/**
|
||||
* Portal auth wiring — the seam the SaaS build overrides.
|
||||
*
|
||||
* <p>Self-hosted (this base): the portal is its own standalone app, so it owns a
|
||||
* Spring {@link AuthProvider} and a Spring login gate ({@link AuthGate}). The SaaS
|
||||
* build shadows this file to authenticate against the SaaS Supabase project (the
|
||||
* same session the editor uses) with no Spring login and no account-link step.
|
||||
*/
|
||||
export function PortalAuthBoundary({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthProvider mode="spring">
|
||||
<AuthGate>{children}</AuthGate>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,15 @@
|
||||
* bouncing non-admins out).
|
||||
*
|
||||
* Sourced from VITE_EDITOR_URL so it's configurable per deploy rather than
|
||||
* hardcoded. The committed default is "/" (production serves the editor at the
|
||||
* root on the same origin as the portal). For dev cross-app navigation to a
|
||||
* separately-running editor, set VITE_EDITOR_URL in editor/.env.local.
|
||||
* hardcoded, falling back to "/" (the editor serves the portal at /portal on
|
||||
* the same origin, so the root is the editor). For dev cross-app navigation to
|
||||
* a separately-running editor, set VITE_EDITOR_URL in editor/.env.local.
|
||||
*/
|
||||
export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL;
|
||||
export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL || "/";
|
||||
|
||||
/**
|
||||
* When the editor is at the root of this origin, the portal and editor are
|
||||
* route-sets of one SPA — switching apps can be a client-side navigation
|
||||
* instead of a full page load.
|
||||
*/
|
||||
export const EDITOR_IS_SAME_APP = EDITOR_URL === "/";
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Hoisted so the vi.mock factories can reference it before the import runs.
|
||||
const { state } = vi.hoisted(() => ({
|
||||
state: { client: null as { auth: { getSession: () => unknown } } | null },
|
||||
}));
|
||||
|
||||
vi.mock("@app/auth/supabase/supabaseClient", () => ({
|
||||
getSupabaseClient: () => state.client,
|
||||
}));
|
||||
vi.mock("@portal/auth/saasSupabase", () => ({
|
||||
ensureSaasSupabase: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getPortalSaasToken } from "@portal/auth/portalSaasSession";
|
||||
|
||||
describe("getPortalSaasToken — self-hosted (account-link login)", () => {
|
||||
beforeEach(() => {
|
||||
state.client = null;
|
||||
});
|
||||
|
||||
it("returns null when the SaaS client isn't configured (not signed in)", async () => {
|
||||
expect(await getPortalSaasToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the access token from the current session", async () => {
|
||||
state.client = {
|
||||
auth: {
|
||||
getSession: async () => ({
|
||||
data: { session: { access_token: "tok-123" } },
|
||||
}),
|
||||
},
|
||||
};
|
||||
expect(await getPortalSaasToken()).toBe("tok-123");
|
||||
});
|
||||
|
||||
it("returns null when there is a client but no active session", async () => {
|
||||
state.client = {
|
||||
auth: { getSession: async () => ({ data: { session: null } }) },
|
||||
};
|
||||
expect(await getPortalSaasToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
|
||||
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
|
||||
|
||||
/**
|
||||
* SaaS access token for attended portal→SaaS reads. Reads the shared Supabase
|
||||
* client, which {@link ensureSaasSupabase} configures against the one Stirling
|
||||
* Supabase project (VITE_SUPABASE_*) both flavors talk to: self-hosted mints a
|
||||
* SaaS JWT in-app for account-linking (alongside its own Spring session); SaaS is
|
||||
* already signed into that same project, so the editor session is inherited.
|
||||
* Flavor-agnostic — no per-flavor override needed. Returns null until a session
|
||||
* exists.
|
||||
*/
|
||||
export async function getPortalSaasToken(): Promise<string | null> {
|
||||
ensureSaasSupabase();
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) return null;
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data.session?.access_token ?? null;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock only the low-level client factory — NOT ensureSaasSupabase itself — so this
|
||||
// exercises the real configurator and proves it wires the shared client from the
|
||||
// one Stirling Supabase env (VITE_SUPABASE_*), shared by every flavor.
|
||||
const configureSupabase = vi.fn();
|
||||
const getSupabaseClient = vi.fn(() => ({ __client: true }));
|
||||
vi.mock("@app/auth/supabase/supabaseClient", () => ({
|
||||
configureSupabase,
|
||||
getSupabaseClient,
|
||||
}));
|
||||
|
||||
describe("ensureSaasSupabase — configures the shared client from VITE_SUPABASE_*", () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
configureSupabase.mockClear();
|
||||
getSupabaseClient.mockClear();
|
||||
});
|
||||
|
||||
it("configures from VITE_SUPABASE_* and returns the client", async () => {
|
||||
vi.stubEnv("VITE_SUPABASE_URL", "https://proj.supabase.co");
|
||||
vi.stubEnv("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY", "anon-key");
|
||||
// Dynamic import so the module reads the stubbed env at load.
|
||||
const { ensureSaasSupabase, isSaasSupabaseConfigured } =
|
||||
await import("@portal/auth/saasSupabase");
|
||||
expect(isSaasSupabaseConfigured).toBe(true);
|
||||
const client = ensureSaasSupabase();
|
||||
expect(configureSupabase).toHaveBeenCalledWith({
|
||||
url: "https://proj.supabase.co",
|
||||
key: "anon-key",
|
||||
});
|
||||
expect(client).not.toBeNull();
|
||||
});
|
||||
|
||||
it("stays unconfigured (client null) when the Supabase env is absent", async () => {
|
||||
vi.stubEnv("VITE_SUPABASE_URL", "");
|
||||
vi.stubEnv("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY", "");
|
||||
const { ensureSaasSupabase, isSaasSupabaseConfigured } =
|
||||
await import("@portal/auth/saasSupabase");
|
||||
expect(isSaasSupabaseConfigured).toBe(false);
|
||||
expect(ensureSaasSupabase()).toBeNull();
|
||||
expect(configureSupabase).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,18 +4,21 @@ import {
|
||||
} from "@app/auth/supabase/supabaseClient";
|
||||
|
||||
/**
|
||||
* Configures the shared Supabase client against the hosted SaaS project so the
|
||||
* portal can mint a SaaS JWT IN-APP for account linking (no popup). This is a
|
||||
* Configures the shared Supabase client against the Stirling Supabase project so
|
||||
* the portal can mint a SaaS JWT IN-APP for account linking (no popup). This is a
|
||||
* separate, transient SaaS auth — the portal's own session stays Spring (the
|
||||
* local instance admin); calls to the local backend still carry the Spring
|
||||
* bearer, and the SaaS JWT is passed only in the link request body.
|
||||
*
|
||||
* Config: VITE_SAAS_SUPABASE_URL + VITE_SAAS_SUPABASE_ANON_KEY (both public).
|
||||
* Absent → {@link isSaasSupabaseConfigured} is false and the link UI degrades to
|
||||
* a "configure the SaaS Supabase URL" state.
|
||||
* Config: VITE_SUPABASE_URL + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY (both public)
|
||||
* — the one Stirling Supabase project every flavor talks to (same vars the editor
|
||||
* and proprietary billing client use; there is no separate SaaS project). SaaS
|
||||
* needs no per-flavor override: the signed-in editor session is on this same
|
||||
* project, so the client picks it up. Absent → {@link isSaasSupabaseConfigured}
|
||||
* is false and the link UI degrades to a "configure Supabase" state.
|
||||
*/
|
||||
const url = import.meta.env.VITE_SAAS_SUPABASE_URL;
|
||||
const key = import.meta.env.VITE_SAAS_SUPABASE_ANON_KEY;
|
||||
const url = import.meta.env.VITE_SUPABASE_URL;
|
||||
const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
|
||||
|
||||
export const isSaasSupabaseConfigured = Boolean(url && key);
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ async function invoke<T>(
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) {
|
||||
throw new StripeFunctionError(
|
||||
"SaaS Supabase not configured — set VITE_SAAS_SUPABASE_URL.",
|
||||
"SaaS Supabase not configured — set VITE_SUPABASE_URL.",
|
||||
"unconfigured",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,27 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NavItem } from "@app/ui";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useLink } from "@portal/contexts/LinkContext";
|
||||
import { LinkIcon } from "@portal/components/icons";
|
||||
|
||||
/**
|
||||
* Sidebar-footer link-account CTA. Only visible when the org is unlinked — once
|
||||
* linked, the linked-instances row + plan badge already communicate the state,
|
||||
* so a permanent footer button would be noise. Click → opens the login modal
|
||||
* directly. The SaaS build shadows this file with a no-op: the signed-in account
|
||||
* IS the SaaS account, so there is nothing to link.
|
||||
*/
|
||||
export function LinkAccountFooterItem() {
|
||||
const { t } = useTranslation();
|
||||
const { openLinkModal } = useUI();
|
||||
const { linkState } = useLink();
|
||||
if (linkState !== "unlinked") return null;
|
||||
return (
|
||||
<NavItem
|
||||
id="account-link"
|
||||
label={t("portal.shell.sidebar.linkAccount", "Link Stirling account")}
|
||||
icon={<LinkIcon />}
|
||||
onClick={() => openLinkModal()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { AppShell } from "@portal/components/AppShell";
|
||||
import { AssistantButton } from "@portal/components/AssistantButton";
|
||||
import { AssistantPanel } from "@portal/components/AssistantPanel";
|
||||
import { SearchModal } from "@portal/components/SearchModal";
|
||||
import { SettingsModal } from "@portal/components/SettingsModal";
|
||||
import { ViewRouter } from "@portal/ViewRouter";
|
||||
|
||||
/**
|
||||
* Global keyboard shortcuts. Lives below the UIProvider so it can dispatch into
|
||||
* the overlay state. Currently just ⌘K / Ctrl+K to toggle the search palette.
|
||||
*/
|
||||
function GlobalShortcuts() {
|
||||
const { toggleSearch, closeSearch } = useUI();
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
|
||||
if (isCmdK) {
|
||||
e.preventDefault();
|
||||
toggleSearch();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
closeSearch();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [toggleSearch, closeSearch]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Bridges the Settings modal's open/close props to UIContext state. */
|
||||
function SettingsHost() {
|
||||
const { settingsOpen, settingsInitialSection, closeSettings } = useUI();
|
||||
return (
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={closeSettings}
|
||||
initialSection={settingsInitialSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The routed view, wrapped in an error boundary so a single view crashing can't
|
||||
* white-screen the portal (the shell + nav stay alive). Keyed by route so
|
||||
* navigating to another section clears any error from the previous one.
|
||||
*/
|
||||
function RoutedContent() {
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
<ErrorBoundary key={pathname}>
|
||||
<ViewRouter />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The flavor-agnostic portal chrome: the shell (sidebar + header + routed view)
|
||||
* plus the global overlays that every flavor shares. Requires only the Tier and
|
||||
* UI contexts above it — both flavors provide those. Flavor-specific overlays
|
||||
* (e.g. the self-hosted account-link modal) are mounted by PortalProviders, not
|
||||
* here.
|
||||
*/
|
||||
export function PortalChrome() {
|
||||
return (
|
||||
<>
|
||||
<GlobalShortcuts />
|
||||
<AppShell>
|
||||
<RoutedContent />
|
||||
</AppShell>
|
||||
<AssistantButton />
|
||||
<AssistantPanel />
|
||||
<SearchModal />
|
||||
<SettingsHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,9 +31,8 @@ import {
|
||||
PoliciesIcon,
|
||||
InfrastructureIcon,
|
||||
SparklesIcon,
|
||||
LinkIcon,
|
||||
} from "@portal/components/icons";
|
||||
import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel";
|
||||
import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings";
|
||||
import "@portal/components/SettingsModal.css";
|
||||
|
||||
type SettingsSection =
|
||||
@@ -150,11 +149,17 @@ export function SettingsModal({
|
||||
{
|
||||
title: t("portal.settings.groups.admin"),
|
||||
items: [
|
||||
{
|
||||
key: "account-link",
|
||||
label: t("portal.settings.sections.account-link"),
|
||||
icon: <LinkIcon size={16} />,
|
||||
},
|
||||
// Account-link is a self-hosted-only section; the SaaS build shadows
|
||||
// the seam to null, dropping the item entirely.
|
||||
...(accountLinkSettings
|
||||
? [
|
||||
{
|
||||
key: accountLinkSettings.navKey,
|
||||
label: t(accountLinkSettings.labelKey),
|
||||
icon: accountLinkSettings.icon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "authentication",
|
||||
label: t("portal.settings.sections.authentication"),
|
||||
@@ -337,7 +342,9 @@ export function SettingsModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "account-link" && <AccountLinkPanel />}
|
||||
{section === "account-link" && accountLinkSettings && (
|
||||
<accountLinkSettings.Body />
|
||||
)}
|
||||
</SettingsShell>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -40,35 +40,12 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* App switcher (down-arrow → Portal / Editor) */
|
||||
/* App switcher (down-arrow → Portal / Editor); button and menu styling live
|
||||
with the shared AppSwitch element. */
|
||||
.portal-sidebar__app-switch {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
}
|
||||
.portal-sidebar__app-switch-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-4);
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.portal-sidebar__app-switch-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.portal-sidebar__app-icon {
|
||||
width: 1rem;
|
||||
height: 1.0625rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Nav body */
|
||||
.portal-sidebar__nav {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Button, Dropdown, NavItem } from "@app/ui";
|
||||
import { NavItem } from "@app/ui";
|
||||
import { AppSwitch } from "@app/components/shared/AppSwitch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useLink } from "@portal/contexts/LinkContext";
|
||||
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
|
||||
import { EDITOR_URL } from "@portal/auth/editorUrl";
|
||||
import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
|
||||
import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
|
||||
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
|
||||
import {
|
||||
@@ -20,10 +22,8 @@ import {
|
||||
ComponentsIcon,
|
||||
InfrastructureIcon,
|
||||
UsageIcon,
|
||||
LinkIcon,
|
||||
DocsIcon,
|
||||
SettingsIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@portal/components/icons";
|
||||
import "@portal/components/Sidebar.css";
|
||||
|
||||
@@ -49,27 +49,6 @@ const GROUP_PLATFORM: NavEntry[] = [
|
||||
{ id: "docs", icon: <DocsIcon /> },
|
||||
];
|
||||
|
||||
/**
|
||||
* Sidebar-footer link-account CTA. Only visible when the org is unlinked — once
|
||||
* linked, the linked-instances row + plan badge already communicate the state,
|
||||
* so a permanent footer button would be noise. Click → opens the login modal
|
||||
* directly.
|
||||
*/
|
||||
function LinkAccountFooterItem() {
|
||||
const { t } = useTranslation();
|
||||
const { openLinkModal } = useUI();
|
||||
const { linkState } = useLink();
|
||||
if (linkState !== "unlinked") return null;
|
||||
return (
|
||||
<NavItem
|
||||
id="account-link"
|
||||
label={t("portal.shell.sidebar.linkAccount", "Link Stirling account")}
|
||||
icon={<LinkIcon />}
|
||||
onClick={() => openLinkModal()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageFooter() {
|
||||
const { tier } = useTier();
|
||||
const { t } = useTranslation();
|
||||
@@ -136,6 +115,15 @@ export function Sidebar() {
|
||||
const { theme } = useTheme();
|
||||
const { openSettings } = useUI();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Editor and portal are one SPA when the editor serves this origin's root, so
|
||||
// the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup)
|
||||
// needs a full page load.
|
||||
const goToEditor = () => {
|
||||
if (EDITOR_IS_SAME_APP) navigate("/");
|
||||
else window.location.href = EDITOR_URL;
|
||||
};
|
||||
|
||||
// Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into
|
||||
// a takeover modal (matching the marketing prototype).
|
||||
@@ -170,45 +158,12 @@ export function Sidebar() {
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<Dropdown.Root align="end" className="portal-sidebar__app-switch">
|
||||
<Dropdown.Trigger>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="portal-sidebar__app-switch-btn"
|
||||
aria-label={t("portal.shell.sidebar.switchApp")}
|
||||
>
|
||||
<ChevronDownIcon size={14} />
|
||||
</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="11rem">
|
||||
<Dropdown.Item
|
||||
active
|
||||
leading={
|
||||
<img
|
||||
className="portal-sidebar__app-icon"
|
||||
src={theme === "dark" ? markDark : markLight}
|
||||
alt=""
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("portal.shell.sidebar.appProcessor")}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onSelect={() => {
|
||||
window.location.href = EDITOR_URL;
|
||||
}}
|
||||
leading={
|
||||
<img
|
||||
className="portal-sidebar__app-icon"
|
||||
src={theme === "dark" ? markDark : markLight}
|
||||
alt=""
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("portal.shell.sidebar.appEditor")}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
<AppSwitch
|
||||
className="portal-sidebar__app-switch"
|
||||
current="processor"
|
||||
theme={theme}
|
||||
onSwitch={goToEditor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="portal-sidebar__nav">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export function LinkAccountCard({ link }: Props) {
|
||||
)}
|
||||
>
|
||||
{t("portal.accountLink.card.loginNotConfigured.before", "Set")}{" "}
|
||||
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
|
||||
<code>VITE_SUPABASE_URL</code>{" "}
|
||||
{t(
|
||||
"portal.accountLink.card.loginNotConfigured.after",
|
||||
"to enable account linking against the hosted Stirling account. In dev you can simulate sign-in from the link dialog.",
|
||||
|
||||
@@ -96,9 +96,9 @@ export function LinkAccountModal({
|
||||
)}
|
||||
>
|
||||
{t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "}
|
||||
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
|
||||
<code>VITE_SUPABASE_URL</code>{" "}
|
||||
{t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "}
|
||||
<code>VITE_SAAS_SUPABASE_ANON_KEY</code>{" "}
|
||||
<code>VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY</code>{" "}
|
||||
{t(
|
||||
"portal.accountLink.modal.loginNotConfigured.after",
|
||||
"to enable in-app linking against the hosted Stirling account.",
|
||||
|
||||
@@ -36,10 +36,7 @@ export function EnterpriseUpsell({ bare = false }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setActiveView("procurement")}>
|
||||
{t(
|
||||
"portal.billing.enterpriseUpsell.cta",
|
||||
"Build your Enterprise quote",
|
||||
)}
|
||||
{t("portal.billing.enterpriseUpsell.cta", "Explore Enterprise")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
const linkState = { isLinked: false };
|
||||
vi.mock("@portal/contexts/LinkContext", () => ({
|
||||
useLink: () => linkState,
|
||||
useApplyLinkFacts: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("@portal/contexts/UIContext", () => ({
|
||||
useUI: () => ({ openLinkModal: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@portal/components/billing/LinkAccountPrompt", () => ({
|
||||
LinkAccountPrompt: () => <div data-testid="link-prompt" />,
|
||||
}));
|
||||
vi.mock("@portal/views/Usage", () => ({
|
||||
Usage: () => <div data-testid="usage" />,
|
||||
}));
|
||||
|
||||
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
|
||||
|
||||
describe("PortalBillingGate — self-hosted", () => {
|
||||
beforeEach(() => {
|
||||
linkState.isLinked = false;
|
||||
});
|
||||
|
||||
it("shows the link prompt when unlinked (billing gated on link)", () => {
|
||||
linkState.isLinked = false;
|
||||
render(<PortalBillingGate />);
|
||||
expect(screen.getByTestId("link-prompt")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("usage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Usage page once linked", () => {
|
||||
linkState.isLinked = true;
|
||||
render(<PortalBillingGate />);
|
||||
expect(screen.getByTestId("usage")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("link-prompt")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback } from "react";
|
||||
import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt";
|
||||
import { Usage } from "@portal/views/Usage";
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
|
||||
/**
|
||||
* Billing access gate — the seam the SaaS build overrides.
|
||||
*
|
||||
* <p>Self-hosted (this base): billing only makes sense once the instance has
|
||||
* linked its SaaS account, so gate on link state — unlinked shows the link prompt;
|
||||
* linked renders the (flavor-agnostic) Usage page and maps its callbacks onto the
|
||||
* link/tier dimension: the wallet's subscription status refines the plan/tier
|
||||
* badge, and a lapsed SaaS session re-opens the account-link re-auth. This keeps
|
||||
* the "link" concept entirely out of the Usage page. The SaaS build shadows this
|
||||
* with a passthrough — there is no linking there.
|
||||
*/
|
||||
export function PortalBillingGate() {
|
||||
const { isLinked } = useLink();
|
||||
const applyLinkFacts = useApplyLinkFacts();
|
||||
const { openLinkModal } = useUI();
|
||||
|
||||
const onWalletLoaded = useCallback(
|
||||
(w: Wallet) => applyLinkFacts(true, w.status === "subscribed"),
|
||||
[applyLinkFacts],
|
||||
);
|
||||
const onReauth = useCallback(() => openLinkModal("reauth"), [openLinkModal]);
|
||||
|
||||
if (!isLinked) return <LinkAccountPrompt />;
|
||||
return <Usage onWalletLoaded={onWalletLoaded} onReauth={onReauth} />;
|
||||
}
|
||||
@@ -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 { useLink } from "@portal/contexts/LinkContext";
|
||||
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 } = useLink();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { LinkIcon } from "@portal/components/icons";
|
||||
import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel";
|
||||
|
||||
export interface AccountLinkSettingsSeam {
|
||||
/** Section key in the Settings nav + body switch. */
|
||||
navKey: string;
|
||||
/** i18n key for the nav label; resolved with `t()` at the call site. */
|
||||
labelKey: string;
|
||||
icon: ReactNode;
|
||||
/** The section body — the account-link panel. */
|
||||
Body: ComponentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin "Account link" section of Settings (self-hosted only). The SaaS
|
||||
* build shadows this file with `null`: the signed-in account IS the SaaS
|
||||
* account, so there is no instance to link — the nav item and its panel both
|
||||
* drop out, and nothing imports the link-only AccountLinkPanel.
|
||||
*/
|
||||
export const accountLinkSettings: AccountLinkSettingsSeam | null = {
|
||||
navKey: "account-link",
|
||||
labelKey: "portal.settings.sections.account-link",
|
||||
icon: <LinkIcon size={16} />,
|
||||
Body: AccountLinkPanel,
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { readMocksPreference } from "@portal/mocks/preference";
|
||||
import { useLink, type LinkState } from "@portal/contexts/LinkContext";
|
||||
import { usePlanTier } from "@portal/contexts/usePlanTier";
|
||||
|
||||
export type Tier = "free" | "pro" | "enterprise";
|
||||
|
||||
@@ -34,17 +34,6 @@ interface TierContextValue {
|
||||
|
||||
const TierContext = createContext<TierContextValue | null>(null);
|
||||
|
||||
/** Maps the real link/subscription state onto the tier the rest of the portal reads. */
|
||||
function tierFromLinkState(linkState: LinkState): Tier {
|
||||
switch (linkState) {
|
||||
case "linked-subscribed":
|
||||
return "pro";
|
||||
case "linked-free":
|
||||
case "unlinked":
|
||||
return "free";
|
||||
}
|
||||
}
|
||||
|
||||
export function TierProvider({
|
||||
children,
|
||||
initialTier = "pro",
|
||||
@@ -55,28 +44,30 @@ export function TierProvider({
|
||||
// Mocks toggling reloads the page (see MocksToggle), so a single read at mount
|
||||
// is correct — the preference can't change without us remounting.
|
||||
const mocksOn = useMemo(() => readMocksPreference(), []);
|
||||
const { linkState } = useLink();
|
||||
// Real derived tier. Its source is a per-flavor seam: self-hosted derives it
|
||||
// from the link/subscription state, SaaS from the wallet (see usePlanTier).
|
||||
const derivedTier = usePlanTier();
|
||||
|
||||
const [mockTier, setMockTier] = useState<Tier>(initialTier);
|
||||
|
||||
// When mocks are off, mirror the real link state into the tier so any
|
||||
// component still keyed on `tier` (sidebar plan badge, gated panels) stays
|
||||
// consistent with the wallet. When mocks are on, the dropdown wins.
|
||||
// When mocks are off, mirror the derived tier so any component keyed on `tier`
|
||||
// (sidebar plan badge, gated panels) stays consistent. When mocks are on, the
|
||||
// dropdown wins.
|
||||
useEffect(() => {
|
||||
if (!mocksOn) {
|
||||
setMockTier(tierFromLinkState(linkState));
|
||||
setMockTier(derivedTier);
|
||||
}
|
||||
}, [mocksOn, linkState]);
|
||||
}, [mocksOn, derivedTier]);
|
||||
|
||||
const value = useMemo<TierContextValue>(
|
||||
() => ({
|
||||
tier: mocksOn ? mockTier : tierFromLinkState(linkState),
|
||||
tier: mocksOn ? mockTier : derivedTier,
|
||||
// Setter is a no-op when mocks are off — UI controls can disable themselves
|
||||
// via `isDerived`, but even if one slips through, it has no effect.
|
||||
setTier: mocksOn ? setMockTier : () => {},
|
||||
isDerived: !mocksOn,
|
||||
}),
|
||||
[mocksOn, mockTier, linkState],
|
||||
[mocksOn, mockTier, derivedTier],
|
||||
);
|
||||
|
||||
return <TierContext.Provider value={value}>{children}</TierContext.Provider>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
|
||||
export type ViewId =
|
||||
| "home"
|
||||
@@ -55,9 +56,10 @@ export const VIEW_PATHS: Record<ViewId, string> = {
|
||||
* The portal is mounted as a route-set under this base path inside the editor
|
||||
* app (see the admin-route seam). VIEW_PATHS stay expressed as logical portal
|
||||
* paths; this facade adds/strips the base so components keep navigating by
|
||||
* ViewId without knowing where the portal is mounted.
|
||||
* ViewId without knowing where the portal is mounted. The constant lives in
|
||||
* core so portal-free build flavors can reference the mount point too.
|
||||
*/
|
||||
export const PORTAL_BASENAME = "/portal";
|
||||
export { PORTAL_BASENAME };
|
||||
|
||||
/** Logical view path -> full app path (e.g. "/users" -> "/portal/users"). */
|
||||
export function toPortalPath(viewPath: string): string {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user