Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ duplicate) with no credentials. Live mode additionally requires a server-only

The hosted portfolio deployment intentionally omits those private credentials.
It reads the persisted catalog through the public Supabase anon key under RLS,
while live refresh and analysis render an archived-demo state instead of
calling external providers.
while the original refresh and analysis interfaces remain visible but disabled
instead of calling external providers.

Opt-in live smoke test (never part of CI):

Expand Down
11 changes: 1 addition & 10 deletions src/app/analyze/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Metadata } from "next";

import { CapCheckApp } from "@/components/capcheck-app";
import { PortfolioDemoNotice } from "@/components/portfolio-demo-notice";
import { isPortfolioDemoMode } from "@/lib/portfolio-mode";

export const metadata: Metadata = {
Expand All @@ -10,13 +9,5 @@ export const metadata: Metadata = {
};

export default function AnalyzePage() {
if (isPortfolioDemoMode()) {
return (
<main className="feed-page portfolio-demo-page">
<PortfolioDemoNotice feature="analyzer" />
</main>
);
}

return <CapCheckApp />;
return <CapCheckApp readOnly={isPortfolioDemoMode()} />;
}
22 changes: 0 additions & 22 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1453,28 +1453,6 @@ input:disabled {
font-size: 0.92rem;
}

.portfolio-demo-notice {
margin-top: 24px;
}

.portfolio-demo-notice > svg {
color: var(--ink);
}

.portfolio-demo-notice .kicker {
margin-bottom: 6px;
}

.portfolio-demo-notice h2 {
margin: 0 0 6px;
color: var(--ink);
font: 600 clamp(1.2rem, 2.5vw, 1.6rem)/1.2 var(--display);
}

.portfolio-demo-page {
max-width: 760px;
}

.feed-state-action {
display: inline-flex;
align-items: center;
Expand Down
7 changes: 1 addition & 6 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { TriangleAlert } from "lucide-react";
import Link from "next/link";

import { FeedExplorer } from "@/components/feed/feed-explorer";
import { PortfolioDemoNotice } from "@/components/portfolio-demo-notice";
import { RefreshFeedButton } from "@/components/refresh-feed-button";
import type { CatalogItem } from "@/domain/feed";
import { isPortfolioDemoMode } from "@/lib/portfolio-mode";
Expand Down Expand Up @@ -58,11 +57,7 @@ export default async function FeedHome({
only if its claims held up. Open one to see the Cap Score, the
evidence, and the citations behind it.
</p>
{portfolioDemo ? (
<PortfolioDemoNotice feature="refresh" />
) : (
<RefreshFeedButton />
)}
<RefreshFeedButton readOnly={portfolioDemo} />
</section>

{failed ? (
Expand Down
17 changes: 17 additions & 0 deletions src/components/capcheck-app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ describe("CapCheckApp", () => {
expect(screen.getByText(/choose a video file/i)).toBeInTheDocument();
});

it("preserves the analyzer UI while disabling retired production actions", () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { container } = render(<CapCheckApp readOnly />);

expect(
screen.getByRole("heading", { name: /is that stock tip/i }),
).toBeVisible();
expect(landingUrlInput()).toBeDisabled();
expect(checkItButton()).toBeDisabled();
expect(screen.getByLabelText(/choose a video file/i)).toBeDisabled();
expect(screen.getByText(/live analysis is disabled/i)).toBeVisible();

fireEvent.submit(container.querySelector(".intake form")!);
expect(fetchMock).not.toHaveBeenCalled();
});

it("submits a valid URL through the public stream and renders the scorecard", async () => {
const fetchMock = vi.fn().mockResolvedValue(
sseResponse(
Expand Down
8 changes: 5 additions & 3 deletions src/components/capcheck-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const allowedScenarios = new Set([
"fatal",
]);

export function CapCheckApp() {
export function CapCheckApp({ readOnly = false }: { readOnly?: boolean } = {}) {
const [url, setUrl] = useState("");
const [file, setFile] = useState<File | null>(null);
const [nextUrl, setNextUrl] = useState("");
Expand Down Expand Up @@ -52,6 +52,7 @@ export function CapCheckApp() {
}, [uploadPreviewUrl]);

const performAnalysis = async (submitUrl: string, submitFile: File | null) => {
if (readOnly) return;
setValidation("");
setMiniError("");
setError(null);
Expand Down Expand Up @@ -110,7 +111,7 @@ export function CapCheckApp() {
};

const analyze = () => {
if (loading) return;
if (readOnly || loading) return;
const validationMessage = validateSubmission(url, file);
if (validationMessage) {
setError(null);
Expand All @@ -122,7 +123,7 @@ export function CapCheckApp() {

const analyzeNext = (event: FormEvent) => {
event.preventDefault();
if (loading) return;
if (readOnly || loading) return;
const validationMessage = validateSubmission(nextUrl, null);
if (validationMessage) {
setMiniError(validationMessage);
Expand Down Expand Up @@ -209,6 +210,7 @@ export function CapCheckApp() {
url={url}
file={file}
loading={loading}
readOnly={readOnly}
error={error}
validation={validation}
onUrlChange={changeUrl}
Expand Down
16 changes: 10 additions & 6 deletions src/components/intake-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type Props = {
url: string;
file: File | null;
loading: boolean;
readOnly?: boolean;
error: ErrorEvent["error"] | null;
validation: string;
onUrlChange(value: string): void;
Expand All @@ -59,6 +60,7 @@ export function IntakePanel({
url,
file,
loading,
readOnly = false,
error,
validation,
onUrlChange,
Expand Down Expand Up @@ -116,18 +118,20 @@ export function IntakePanel({
id={inputId}
type="url"
value={url}
disabled={loading || Boolean(file)}
disabled={readOnly || loading || Boolean(file)}
aria-invalid={Boolean(validation)}
aria-describedby={describedBy}
placeholder="https://www.youtube.com/shorts/…"
onChange={(event) => onUrlChange(event.target.value)}
/>
<button className="primary" type="submit" disabled={loading}>
<button className="primary" type="submit" disabled={readOnly || loading}>
{loading ? "Checking…" : "Check it"}
</button>
</div>
<p id={`${inputId}-help`} className="helper">
YouTube, TikTok, and Reels links work. Analysis takes about a minute.
{readOnly
? "Portfolio demo — live analysis is disabled."
: "YouTube, TikTok, and Reels links work. Analysis takes about a minute."}
</p>
{validation && (
<p id={`${inputId}-error`} className="field-error" role="alert">
Expand All @@ -137,9 +141,9 @@ export function IntakePanel({

<div className="divider">or upload a video</div>
<label
className={`drop-zone${file ? " selected" : ""}${loading ? " disabled" : ""}`}
className={`drop-zone${file ? " selected" : ""}${readOnly || loading ? " disabled" : ""}`}
htmlFor={fileId}
aria-disabled={loading}
aria-disabled={readOnly || loading}
>
<Upload aria-hidden="true" />
<span>
Expand All @@ -152,7 +156,7 @@ export function IntakePanel({
className="visually-hidden"
type="file"
accept="video/mp4,video/quicktime,video/webm,.mp4,.mov,.webm"
disabled={loading}
disabled={readOnly || loading}
aria-invalid={Boolean(uploadValidation)}
aria-describedby={fileDescribedBy}
onChange={selectFile}
Expand Down
19 changes: 0 additions & 19 deletions src/components/portfolio-demo-notice.test.tsx

This file was deleted.

32 changes: 0 additions & 32 deletions src/components/portfolio-demo-notice.tsx

This file was deleted.

25 changes: 25 additions & 0 deletions src/components/refresh-feed-button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";

import { RefreshFeedButton } from "./refresh-feed-button";

vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
}));

describe("RefreshFeedButton", () => {
it("preserves the refresh control while preventing retired production work", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();

render(<RefreshFeedButton readOnly />);

const button = screen.getByRole("button", { name: "Refresh feed" });
expect(button).toBeDisabled();
expect(screen.getByText(/live refresh is disabled/i)).toBeVisible();
await user.click(button);
expect(fetchMock).not.toHaveBeenCalled();
});
});
16 changes: 12 additions & 4 deletions src/components/refresh-feed-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Props = {
/** Called after a successful refresh so the feed can reload. */
onRefreshed?: (result: RefreshResult) => void;
endpoint?: string;
readOnly?: boolean;
};

const summarize = (counts: RefreshCounts) =>
Expand All @@ -29,7 +30,11 @@ const summarize = (counts: RefreshCounts) =>
* `onRefreshed` to reload the catalog. Uses shared cream tokens via existing
* `primary` / `helper` / `field-error` classes.
*/
export function RefreshFeedButton({ onRefreshed, endpoint = "/api/feed/refresh" }: Props) {
export function RefreshFeedButton({
onRefreshed,
endpoint = "/api/feed/refresh",
readOnly = false,
}: Props) {
const router = useRouter();
const [running, setRunning] = useState(false);
const [stageText, setStageText] = useState("");
Expand All @@ -38,7 +43,7 @@ export function RefreshFeedButton({ onRefreshed, endpoint = "/api/feed/refresh"
const inFlight = useRef(false);

const refresh = useCallback(async () => {
if (inFlight.current) return;
if (readOnly || inFlight.current) return;
inFlight.current = true;
setRunning(true);
setStageText("Starting refresh…");
Expand Down Expand Up @@ -76,20 +81,23 @@ export function RefreshFeedButton({ onRefreshed, endpoint = "/api/feed/refresh"
inFlight.current = false;
setRunning(false);
}
}, [endpoint, onRefreshed, router]);
}, [endpoint, onRefreshed, readOnly, router]);

return (
<div className="refresh-feed">
<button
type="button"
className="primary"
onClick={refresh}
disabled={running}
disabled={readOnly || running}
aria-busy={running}
>
<RefreshCw aria-hidden="true" />
{running ? "Refreshing…" : "Refresh feed"}
</button>
{readOnly && (
<p className="helper">Portfolio demo — live refresh is disabled.</p>
)}
{running && stageText && (
<p className="helper" role="status" aria-live="polite">
{stageText}
Expand Down
Loading