From 9db89c6030fa3ae0fb3ac2131da3cb172f4c7c26 Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Fri, 23 Oct 2026 19:02:09 -0700 Subject: [PATCH 1/3] Remove /sandbox route; drill simulator: CSV template with headers only --- .../lib/marketing-analytics-routes.test.ts | 1 - .../pages/sandbox-front-door.test.tsx | 274 ------------------ .../sandbox-shared-result-base-url.test.ts | 67 ----- frontend/src/app/sandbox/SandboxClient.tsx | 266 ----------------- frontend/src/app/sandbox/opengraph-image.tsx | 13 - frontend/src/app/sandbox/page.tsx | 65 ----- .../results/[id]/SharedSandboxResult.tsx | 61 ---- .../src/app/sandbox/results/[id]/page.tsx | 51 ---- frontend/src/app/sitemap.ts | 7 - .../components/DrillSimulatorClient.tsx | 12 +- .../drill-simulator/lib/record-validator.ts | 8 + .../src/lib/marketing-analytics-routes.ts | 2 +- frontend/src/lib/sandbox-cta.ts | 11 +- frontend/tests/e2e/sandbox-front-door.spec.ts | 107 ------- 14 files changed, 17 insertions(+), 928 deletions(-) delete mode 100644 frontend/src/__tests__/pages/sandbox-front-door.test.tsx delete mode 100644 frontend/src/__tests__/pages/sandbox-shared-result-base-url.test.ts delete mode 100644 frontend/src/app/sandbox/SandboxClient.tsx delete mode 100644 frontend/src/app/sandbox/opengraph-image.tsx delete mode 100644 frontend/src/app/sandbox/page.tsx delete mode 100644 frontend/src/app/sandbox/results/[id]/SharedSandboxResult.tsx delete mode 100644 frontend/src/app/sandbox/results/[id]/page.tsx delete mode 100644 frontend/tests/e2e/sandbox-front-door.spec.ts diff --git a/frontend/src/__tests__/lib/marketing-analytics-routes.test.ts b/frontend/src/__tests__/lib/marketing-analytics-routes.test.ts index 77e1df31e..e6849aff0 100644 --- a/frontend/src/__tests__/lib/marketing-analytics-routes.test.ts +++ b/frontend/src/__tests__/lib/marketing-analytics-routes.test.ts @@ -42,7 +42,6 @@ describe('marketing pageview routes', () => { expect(safeMarketingPath('/portal/portal-123')).toBeNull(); expect(shouldTrackMarketingPageview('/sandbox/results/result-123')).toBe(false); expect(shouldTrackMarketingPageview('/portal/portal-123')).toBe(false); - expect(shouldTrackMarketingPageview('/sandbox')).toBe(true); }); it('normalizes arbitrary public child paths so names and encoded email never become pageview URLs', () => { diff --git a/frontend/src/__tests__/pages/sandbox-front-door.test.tsx b/frontend/src/__tests__/pages/sandbox-front-door.test.tsx deleted file mode 100644 index 5ad31f614..000000000 --- a/frontend/src/__tests__/pages/sandbox-front-door.test.tsx +++ /dev/null @@ -1,274 +0,0 @@ -// #2757 — regression cover for the sandbox front door. -// -// Two failures this locks down: -// 1. /sandbox 404ing while three compare pages promise a CSV sandbox. -// 2. The #2730 shape — CTA copy promising a sandbox while the button goes -// somewhere else, because copy and href were edited independently. - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -vi.mock('@/lib/posthog-provider', () => ({ - usePostHog: () => ({ capture: vi.fn() }), -})); - -vi.mock('@/components/marketing/sandbox-grid/SandboxPdfReport', () => ({ - generateComplianceReport: vi.fn(), -})); - -import { SandboxClient, validateSandboxFile, describeSandboxFailure } from '@/app/sandbox/SandboxClient'; -import { sandboxCta } from '@/lib/sandbox-cta'; - -const RESULT = { - total_events: 2, - compliant_events: 1, - non_compliant_events: 1, - total_kde_errors: 1, - total_rule_failures: 1, - submission_blocked: false, - blocking_reasons: [], - normalizations: [], - events: [ - { - event_index: 0, - cte_type: 'shipping', - traceability_lot_code: 'SV-20260115-001', - product_description: 'Romaine Lettuce', - kde_errors: [], - rules_evaluated: 4, - rules_passed: 4, - rules_failed: 0, - rules_warned: 0, - compliant: true, - blocking_defects: [], - all_results: [], - }, - { - event_index: 1, - cte_type: 'transformation', - traceability_lot_code: 'FC-20260116-A', - product_description: 'Chopped Romaine', - kde_errors: ['Missing reference_document'], - rules_evaluated: 4, - rules_passed: 3, - rules_failed: 1, - rules_warned: 0, - compliant: false, - blocking_defects: [], - all_results: [ - { - rule_title: 'Reference document required', - severity: 'error', - result: 'fail', - why_failed: 'No reference document on the transformation event', - citation: '§1.1350', - remediation: 'Add the work order number', - category: 'kde_presence', - }, - ], - }, - ], -}; - -function csvFile(name = 'events.csv', size = 1_000) { - const file = new File(['cte_type\nshipping\n'], name, { type: 'text/csv' }); - Object.defineProperty(file, 'size', { value: size }); - file.text = () => Promise.resolve('cte_type\nshipping\n'); - return file; -} - -describe('sandbox CTA resolver (#2757)', () => { - it('never promises a CSV sandbox while pointing somewhere that is not the sandbox', () => { - const cta = sandboxCta(); - const promisesSandbox = /sandbox/i.test(cta.blurb); - if (promisesSandbox) { - expect(cta.href).toBe('/sandbox'); - } else { - expect(cta.href).not.toBe('/sandbox'); - } - }); - - it('marks the destination as a real route in both states', () => { - expect(['/sandbox', '/demo']).toContain(sandboxCta().href); - }); - - it('labels the fallback dataset synthetic rather than implying live data', () => { - const cta = sandboxCta(); - if (!cta.enabled) { - expect(cta.blurb).toMatch(/synthetic/i); - } - }); - - it('points at /sandbox and promises CSV evaluation once the surface is enabled', () => { - vi.stubEnv('NEXT_PUBLIC_ENABLE_EXPERIMENTAL_FEATURES', 'true'); - try { - const cta = sandboxCta(); - expect(cta.enabled).toBe(true); - expect(cta.href).toBe('/sandbox'); - expect(cta.blurb).toMatch(/CSV/i); - } finally { - vi.unstubAllEnvs(); - } - }); - - it('falls back to the demo when the surface is off', () => { - vi.stubEnv('NEXT_PUBLIC_ENABLE_EXPERIMENTAL_FEATURES', ''); - vi.stubEnv('NEXT_PUBLIC_ENABLED_EXPERIMENTAL_TOOLS', ''); - try { - const cta = sandboxCta(); - expect(cta.enabled).toBe(false); - expect(cta.href).toBe('/demo'); - expect(cta.blurb).not.toMatch(/drop your csv/i); - } finally { - vi.unstubAllEnvs(); - } - }); - - it('honors the per-tool override without the whole experimental surface', () => { - vi.stubEnv('NEXT_PUBLIC_ENABLE_EXPERIMENTAL_FEATURES', ''); - vi.stubEnv('NEXT_PUBLIC_ENABLED_EXPERIMENTAL_TOOLS', 'sandbox'); - try { - expect(sandboxCta().href).toBe('/sandbox'); - } finally { - vi.unstubAllEnvs(); - } - }); -}); - -describe('compare-page CTAs (#2757)', () => { - // The #2730 regression was copy and href drifting apart on these three - // pages. Rendering them proves both come from the same resolver. - it.each([ - ['tracegains', () => import('@/app/compare/tracegains/page')], - ['repositrak', () => import('@/app/compare/repositrak/page')], - ['foodlogiq', () => import('@/app/compare/foodlogiq/page')], - ])('%s CTA copy and destination agree', async (_name, load) => { - const { default: Page } = await load(); - const { container } = render(); - - const cta = sandboxCta(); - expect(screen.getByText(cta.blurb)).toBeInTheDocument(); - - const link = container.querySelector(`a[href="${cta.href}"]`); - expect(link).not.toBeNull(); - expect(link).toHaveTextContent(cta.label); - - // No page may claim a CSV sandbox while the sandbox surface is off. - if (!cta.enabled) { - expect(container.textContent).not.toMatch(/drop your csv in the sandbox/i); - } - }); -}); - -describe('validateSandboxFile (#2757)', () => { - it('tells spreadsheet users to export CSV instead of failing silently', () => { - const problem = validateSandboxFile({ name: 'events.xlsx', size: 1_000 }); - expect(problem).toMatch(/CSV/); - expect(problem).toMatch(/Save As|Export/i); - }); - - it('rejects files above the backend 2 MB cap before the round trip', () => { - expect(validateSandboxFile({ name: 'big.csv', size: 3_000_000 })).toMatch(/2 MB/); - }); - - it('accepts csv, tsv and txt', () => { - for (const name of ['a.csv', 'a.tsv', 'a.txt']) { - expect(validateSandboxFile({ name, size: 10 })).toBeNull(); - } - }); - - it('rejects unrelated file types', () => { - expect(validateSandboxFile({ name: 'photo.png', size: 10 })).toMatch(/Unsupported/); - }); -}); - -describe('describeSandboxFailure (#2757)', () => { - it('says the service is not enabled on a 404 rather than blaming the file', () => { - const msg = describeSandboxFailure(404); - expect(msg).toMatch(/not enabled/i); - expect(msg).toMatch(/not evaluated|nothing was uploaded/i); - }); - - it('surfaces the rate limit explicitly on 429', () => { - expect(describeSandboxFailure(429)).toMatch(/Rate limit/i); - }); - - it('prefers the backend detail when one is supplied', () => { - expect(describeSandboxFailure(400, 'CSV parsing error: no cte_type column')).toMatch( - /no cte_type column/, - ); - }); -}); - -describe('SandboxClient (#2757)', () => { - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn()); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it('renders the diagnosis for an uploaded CSV', async () => { - (globalThis.fetch as ReturnType).mockResolvedValue({ - ok: true, - status: 200, - json: async () => RESULT, - }); - - render(); - await userEvent.upload(screen.getByLabelText(/choose a csv/i), csvFile()); - - await waitFor(() => { - expect(screen.getByText('FSMA 204 Sandbox Diagnosis')).toBeInTheDocument(); - }); - expect(screen.getByText('SV-20260115-001')).toBeInTheDocument(); - expect(screen.getByText('FC-20260116-A')).toBeInTheDocument(); - }); - - it('posts to the unauthenticated BFF proxy path, not the backend directly', async () => { - (globalThis.fetch as ReturnType).mockResolvedValue({ - ok: true, - status: 200, - json: async () => RESULT, - }); - - render(); - await userEvent.upload(screen.getByLabelText(/choose a csv/i), csvFile()); - - await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); - const [url, init] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('/api/ingestion/api/v1/sandbox/evaluate'); - expect(init.method).toBe('POST'); - // No auth header — the proxy allowlists this path unauthenticated. - expect(Object.keys(init.headers)).toEqual(['Content-Type']); - }); - - it('states the service is unavailable when the router is not mounted', async () => { - (globalThis.fetch as ReturnType).mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({}), - }); - - render(); - await userEvent.upload(screen.getByLabelText(/choose a csv/i), csvFile()); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/not enabled on this deployment/i); - }); - expect(screen.queryByText('FSMA 204 Sandbox Diagnosis')).not.toBeInTheDocument(); - }); - - it('does not call the backend for a spreadsheet upload', async () => { - render(); - await userEvent.upload(screen.getByLabelText(/choose a csv/i), csvFile('events.xlsx')); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/CSV/); - }); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/__tests__/pages/sandbox-shared-result-base-url.test.ts b/frontend/src/__tests__/pages/sandbox-shared-result-base-url.test.ts deleted file mode 100644 index 180242757..000000000 --- a/frontend/src/__tests__/pages/sandbox-shared-result-base-url.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// Regression guard for the 2026-05-14 login outage root cause: the shared -// sandbox result page must build its fetch base URL from NEXT_PUBLIC_SITE_URL -// only, never from VERCEL_URL (the deployment-specific host). - -const ORIGINAL_SITE_URL = process.env.NEXT_PUBLIC_SITE_URL; -const ORIGINAL_VERCEL_URL = process.env.VERCEL_URL; - -function restore(name: 'NEXT_PUBLIC_SITE_URL' | 'VERCEL_URL', value: string | undefined) { - if (value === undefined) { - delete process.env[name]; - } else { - process.env[name] = value; - } -} - -describe('shared sandbox result base URL resolution', () => { - beforeEach(() => { - vi.resetModules(); - delete process.env.NEXT_PUBLIC_SITE_URL; - delete process.env.VERCEL_URL; - }); - - afterEach(() => { - restore('NEXT_PUBLIC_SITE_URL', ORIGINAL_SITE_URL); - restore('VERCEL_URL', ORIGINAL_VERCEL_URL); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - async function renderWithFetchCapture(): Promise { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ ok: true }), - }); - vi.stubGlobal('fetch', fetchMock); - - const mod = await import('@/app/sandbox/results/[id]/page'); - const Page = mod.default; - await Page({ params: Promise.resolve({ id: 'abc123' }) }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - return fetchMock.mock.calls[0][0] as string; - } - - it('falls back to localhost when NEXT_PUBLIC_SITE_URL is unset, never VERCEL_URL', async () => { - process.env.VERCEL_URL = 'regengine-xxxx.vercel.app'; - - const url = await renderWithFetchCapture(); - - expect(url).toContain('http://localhost:3000/'); - expect(url).not.toContain('regengine-xxxx.vercel.app'); - expect(url).not.toContain('https://undefined'); - }); - - it('uses NEXT_PUBLIC_SITE_URL when set, ignoring VERCEL_URL', async () => { - process.env.NEXT_PUBLIC_SITE_URL = 'https://app.regengine.com'; - process.env.VERCEL_URL = 'regengine-xxxx.vercel.app'; - - const url = await renderWithFetchCapture(); - - expect(url).toContain('https://app.regengine.com/'); - expect(url).not.toContain('regengine-xxxx.vercel.app'); - }); -}); diff --git a/frontend/src/app/sandbox/SandboxClient.tsx b/frontend/src/app/sandbox/SandboxClient.tsx deleted file mode 100644 index a5c6b32d0..000000000 --- a/frontend/src/app/sandbox/SandboxClient.tsx +++ /dev/null @@ -1,266 +0,0 @@ -'use client'; - -// #2757 - the CSV upload front door. Posts through the BFF proxy -// (/api/ingestion/... → POST /api/v1/sandbox/evaluate), which is on the -// unauthenticated allowlist, so no auth and no CSRF token are involved. -// Limits mirror the backend exactly (services/ingestion/app/sandbox/router.py): -// 2 MB of CSV text, 500 events, 30 requests/minute per IP. - -import { useCallback, useRef, useState } from 'react'; -import Link from 'next/link'; -import { ArrowRight, FileUp, Loader2, RotateCcw, ShieldOff, TriangleAlert } from 'lucide-react'; -import { usePostHog } from '@/lib/posthog-provider'; -import { SandboxResultView, type SandboxResult } from '@/components/marketing/sandbox-grid/SandboxResultView'; - -const EVALUATE_URL = '/api/ingestion/api/v1/sandbox/evaluate'; -const SAMPLE_URL = '/samples/sandbox_sample_ftl.csv'; - -/** Backend rejects >2 MB with a 413; fail locally so the user isn't waiting on a round trip. */ -const MAX_CSV_BYTES = 2_000_000; - -const TEXT_EXTENSIONS = ['.csv', '.tsv', '.txt']; -const SPREADSHEET_EXTENSIONS = ['.xlsx', '.xls', '.xlsm', '.numbers', '.ods']; - -function extensionOf(name: string): string { - const dot = name.lastIndexOf('.'); - return dot === -1 ? '' : name.slice(dot).toLowerCase(); -} - -/** - * Returns an error string when the file cannot be sent, or null when it can. - * Spreadsheets get their own message: the frontend has no xlsx parser, and - * silently failing on a .xlsx is the kind of dead end #2757 exists to remove. - */ -export function validateSandboxFile(file: { name: string; size: number }): string | null { - const ext = extensionOf(file.name); - if (SPREADSHEET_EXTENSIONS.includes(ext)) { - return `RegEngine's sandbox reads CSV, not ${ext} directly. In Excel or Numbers choose File → Save As (or Export) → CSV, then upload that file.`; - } - if (ext && !TEXT_EXTENSIONS.includes(ext)) { - return `Unsupported file type ${ext}. Upload a .csv export of your traceability events.`; - } - if (file.size > MAX_CSV_BYTES) { - return `That file is ${(file.size / 1_000_000).toFixed(1)} MB. The sandbox evaluates up to 2 MB. Contact us for unlimited evaluation.`; - } - return null; -} - -/** Maps a failed evaluate response to copy that says what actually happened. */ -export function describeSandboxFailure(status: number, detail?: string): string { - if (status === 404) { - return 'The sandbox evaluation service is not enabled on this deployment, so your file was not evaluated. Nothing was uploaded or stored.'; - } - if (status === 429) { - return 'Rate limit reached (30 evaluations per minute). Wait a minute and try again.'; - } - if (detail) return detail; - if (status === 503) { - return 'The sandbox evaluation service is temporarily unavailable. Nothing was stored. Please try again shortly.'; - } - return `The sandbox could not evaluate that file (HTTP ${status}).`; -} - -export function SandboxClient() { - const posthog = usePostHog(); - const inputRef = useRef(null); - - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [result, setResult] = useState(null); - const [fileName, setFileName] = useState(null); - - const evaluate = useCallback( - async (csv: string, label: string) => { - setBusy(true); - setError(null); - setResult(null); - setFileName(label); - - try { - const res = await fetch(EVALUATE_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ csv }), - }); - - if (!res.ok) { - let detail: string | undefined; - try { - const body = await res.json(); - detail = typeof body?.detail === 'string' ? body.detail : undefined; - } catch { - // Non-JSON error body (proxy/CDN page); the status alone drives the copy. - } - setError(describeSandboxFailure(res.status, detail)); - posthog.capture('SANDBOX_EVALUATE_FAILED', { status: res.status }); - return; - } - - const data: SandboxResult = await res.json(); - setResult(data); - posthog.capture('SANDBOX_EVALUATE_SUCCEEDED', { - total_events: data.total_events, - non_compliant_events: data.non_compliant_events, - }); - } catch { - setError('Could not reach the sandbox. Check your connection and try again. Nothing was stored.'); - posthog.capture('SANDBOX_EVALUATE_FAILED', { status: 0 }); - } finally { - setBusy(false); - } - }, - [posthog], - ); - - const onFile = useCallback( - async (file: File) => { - const problem = validateSandboxFile(file); - if (problem) { - setResult(null); - setFileName(file.name); - setError(problem); - return; - } - await evaluate(await file.text(), file.name); - }, - [evaluate], - ); - - const onSample = useCallback(async () => { - setBusy(true); - try { - const res = await fetch(SAMPLE_URL); - const csv = await res.text(); - await evaluate(csv, 'sandbox_sample_ftl.csv'); - } catch { - setBusy(false); - setError('Could not load the sample file. Try uploading your own CSV.'); - } - }, [evaluate]); - - function reset() { - setResult(null); - setError(null); - setFileName(null); - if (inputRef.current) inputRef.current.value = ''; - } - - return ( -
-
-

- FSMA 204 Sandbox -

-

- Upload a CSV of traceability events and see which §1.1455 key data elements are missing, - which rules fail, and what to fix first. No signup. Nothing is stored; the evaluation is - stateless and the file never leaves the request. -

-

- Up to 500 events and 2 MB per file. Column headers are matched flexibly, so - “Lot #”, “Event Type”, and “UOM” are understood as - written. -

- - {/* Upload */} -
-
- - {busy && ( -
-
- )} - - {error && !busy && ( -
- {error.startsWith('The sandbox evaluation service is not enabled') ? ( -
- )} - - {result && !busy && ( -
-
- {fileName} - -
- - -

- Want this running against your real feeds? -

- - See pricing and apply - -
- } - /> -
- )} -
- - ); -} diff --git a/frontend/src/app/sandbox/opengraph-image.tsx b/frontend/src/app/sandbox/opengraph-image.tsx deleted file mode 100644 index dc28b0dee..000000000 --- a/frontend/src/app/sandbox/opengraph-image.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { renderOgCard, OG_SIZE, OG_CONTENT_TYPE } from '@/lib/og-image'; - -export const runtime = 'edge'; -export const alt = 'RegEngine FSMA 204 sandbox'; -export const size = OG_SIZE; -export const contentType = OG_CONTENT_TYPE; - -export default function Image() { - return renderOgCard({ - eyebrow: 'Sandbox', - title: 'Upload a CSV, see which FSMA 204 data elements are missing', - }); -} diff --git a/frontend/src/app/sandbox/page.tsx b/frontend/src/app/sandbox/page.tsx deleted file mode 100644 index 28a5c17f9..000000000 --- a/frontend/src/app/sandbox/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { Metadata } from 'next'; -import Link from 'next/link'; -import { ArrowRight } from 'lucide-react'; -import { Container } from '@/components/layout/container'; -import { PageHeader } from '@/components/layout/page-header'; -import { Button } from '@/components/ui/button'; -import { SandboxClient } from './SandboxClient'; -import { isSandboxEnabled } from '@/lib/sandbox-cta'; - -// #2757 - restores the front door the compare-page CTAs have promised since -// #2730 removed the dead homepage sandbox hash anchors. The evaluation -// itself is served by -// POST /api/v1/sandbox/evaluate through the existing unauthenticated BFF proxy -// allowlist in app/api/ingestion/[...path]/route.ts. - -export const metadata: Metadata = { - title: 'FSMA 204 Sandbox: Check your CSV | RegEngine', - description: - 'Upload a CSV of traceability events and see which FSMA 204 key data elements are missing. No signup, no data stored.', - openGraph: { - title: 'FSMA 204 Sandbox: Check your CSV', - description: 'Upload a CSV of traceability events and see which FSMA 204 key data elements are missing. No signup, no data stored.', - url: 'https://regengine.co/sandbox', - type: 'website', - }, -}; - -export default function SandboxPage() { - // The sandbox router is experimental (server/main.py _EXPERIMENTAL_ROUTERS) - // and auto-disabled in production. Rather than 404 or show an upload box - // that can only fail, say so plainly and point at what does work. - if (!isSandboxEnabled()) { - return ( - // No
here: the root layout already renders main#main-content, and - // a nested one gave the page two main landmarks. - - -

- RegEngine's stateless CSV evaluation endpoint is part of the experimental router - surface and is turned off here. Nothing is broken on your end; the feature simply is - not switched on for this environment. -

-

- The interactive demo covers the same rule evaluation against a synthetic dataset, and - the walkthrough shows the ingest-to-export path end to end. -

-
- - -
-
- ); - } - - return ; -} diff --git a/frontend/src/app/sandbox/results/[id]/SharedSandboxResult.tsx b/frontend/src/app/sandbox/results/[id]/SharedSandboxResult.tsx deleted file mode 100644 index 34ba0482a..000000000 --- a/frontend/src/app/sandbox/results/[id]/SharedSandboxResult.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'use client'; - -import { Link2 } from 'lucide-react'; -import Link from 'next/link'; -import { useEffect } from 'react'; -import { usePostHog } from '@/lib/posthog-provider'; -// #2757 — the diagnosis markup used to live here and was duplicated by every -// other surface that wanted it. It is now one component shared with /sandbox. -import { SandboxResultView, type SandboxResult } from '@/components/marketing/sandbox-grid/SandboxResultView'; - -export function SharedSandboxResult({ result }: { result: SandboxResult; shareId: string }) { - const posthog = usePostHog(); - - // Track share link views - useEffect(() => { - posthog.capture('SANDBOX_SHARE_VIEWED', { - total_events: result.total_events, - compliant_events: result.compliant_events, - non_compliant_events: result.non_compliant_events, - }); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - return ( -
-
- {/* Shared banner */} -
-
- - - Shared sandbox result - -
- - Try your own data → - -
- - -

- Ready to turn this diagnosis into an operational test run? -

- - Try Your Own Data - -
- } - /> -
- - ); -} diff --git a/frontend/src/app/sandbox/results/[id]/page.tsx b/frontend/src/app/sandbox/results/[id]/page.tsx deleted file mode 100644 index 372a6f6da..000000000 --- a/frontend/src/app/sandbox/results/[id]/page.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { notFound } from 'next/navigation'; -import { SharedSandboxResult } from './SharedSandboxResult'; -import { FeatureNotEnabled } from '@/components/experimental/FeatureNotEnabled'; -import { experimentalFeaturesEnabled } from '@/lib/experimental-features'; - -interface Props { - params: Promise<{ id: string }>; -} - -async function fetchSharedResult(id: string) { - // VERCEL_URL is intentionally NOT consulted: it is the deployment-specific - // host, never the canonical site URL (caused the 2026-05-14 login outage). - const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'; - - const res = await fetch(`${baseUrl}/api/ingestion/api/v1/sandbox/share/${id}`, { - cache: 'no-store', - }); - - if (res.status === 404) return null; - if (!res.ok) return null; - return res.json(); -} - -export default async function SharedResultPage({ params }: Props) { - const { id } = await params; - - if (!experimentalFeaturesEnabled()) { - return ( - - ); - } - - if (!id || id.length > 20) notFound(); - - const result = await fetchSharedResult(id); - if (!result) notFound(); - - return ; -} - -export async function generateMetadata({ params }: Props) { - const { id } = await params; - return { - title: `FSMA 204 Compliance Report | RegEngine`, - description: `Shared sandbox compliance evaluation result (${id})`, - }; -} diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts index 733c47c1d..604c4a42f 100644 --- a/frontend/src/app/sitemap.ts +++ b/frontend/src/app/sitemap.ts @@ -1,7 +1,6 @@ import type { MetadataRoute } from 'next'; import { BLOG_POST_PUBLISHED, BLOG_POST_SLUGS } from '@/lib/blog-posts'; import { EXPERIMENTAL_TOOL_SLUGS, isExperimentalToolEnabled } from '@/lib/experimental-features'; -import { isSandboxEnabled } from '@/lib/sandbox-cta'; /** * Date-only lastmod. Next prints a Date through toISOString(), and an invalid @@ -55,12 +54,6 @@ export default function sitemap(): MetadataRoute.Sitemap { { url: `${baseUrl}/integrations`, changeFrequency: 'monthly' as const, priority: 0.8 }, { url: `${baseUrl}/trust`, changeFrequency: 'monthly' as const, priority: 0.7 }, { url: `${baseUrl}/contact`, changeFrequency: 'yearly' as const, priority: 0.5 }, - // #2757 — only advertise the sandbox when its (experimental) router is - // mounted; listing a page that answers "not enabled here" is worse for - // search than not listing it. - ...(isSandboxEnabled() - ? [{ url: `${baseUrl}/sandbox`, changeFrequency: 'monthly' as const, priority: 0.9 }] - : []), { url: `${baseUrl}/privacy`, changeFrequency: 'yearly' as const, priority: 0.3 }, { url: `${baseUrl}/terms`, changeFrequency: 'yearly' as const, priority: 0.3 }, { url: `${baseUrl}/dpa`, changeFrequency: 'yearly' as const, priority: 0.3 }, diff --git a/frontend/src/app/tools/drill-simulator/components/DrillSimulatorClient.tsx b/frontend/src/app/tools/drill-simulator/components/DrillSimulatorClient.tsx index fec259e65..6a1467c6f 100644 --- a/frontend/src/app/tools/drill-simulator/components/DrillSimulatorClient.tsx +++ b/frontend/src/app/tools/drill-simulator/components/DrillSimulatorClient.tsx @@ -15,7 +15,7 @@ import { } from 'lucide-react'; import { toast } from '@/components/ui/use-toast'; import { - parseCSV, validateRecords, generateSampleCSV, getScoreColor, + parseCSV, validateRecords, generateSampleCSV, generateCsvTemplate, getScoreColor, getSeverityColor, getSeverityBadgeColor, type ValidationResult, } from '../lib/record-validator'; @@ -216,13 +216,13 @@ export function DrillSimulatorClient() { setState({ ...state, uploadedFile: file }); }; - const handleDownloadSample = () => { - const csv = generateSampleCSV(); + const handleDownloadTemplate = () => { + const csv = generateCsvTemplate(); const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = 'sample-traceability-records.csv'; + a.download = 'traceability-records-template.csv'; a.click(); URL.revokeObjectURL(url); }; @@ -373,10 +373,10 @@ STATUS: ${ diff --git a/frontend/src/app/tools/drill-simulator/lib/record-validator.ts b/frontend/src/app/tools/drill-simulator/lib/record-validator.ts index fa8625adf..31658811c 100644 --- a/frontend/src/app/tools/drill-simulator/lib/record-validator.ts +++ b/frontend/src/app/tools/drill-simulator/lib/record-validator.ts @@ -902,6 +902,14 @@ function calculateScores( // UTILITY FUNCTIONS // ============================================================================ +/** Header row only: the columns the drill simulator expects, with no example records. */ +export const CSV_TEMPLATE_HEADERS = + 'Event Type,Product,Lot Code,GTIN,Facility GLN,Source GLN,Destination GLN,Timestamp,Quantity,Unit of Measure,Reference Document'; + +export function generateCsvTemplate(): string { + return `${CSV_TEMPLATE_HEADERS}\n`; +} + export function generateSampleCSV(): string { const lines = [ 'Event Type,Product,Lot Code,GTIN,Facility GLN,Source GLN,Destination GLN,Timestamp,Quantity,Unit of Measure,Reference Document', diff --git a/frontend/src/lib/marketing-analytics-routes.ts b/frontend/src/lib/marketing-analytics-routes.ts index 909c923a5..22232678f 100644 --- a/frontend/src/lib/marketing-analytics-routes.ts +++ b/frontend/src/lib/marketing-analytics-routes.ts @@ -16,7 +16,7 @@ const PUBLIC_MARKETING_SECTIONS = new Set([ '/compare', '/contact', '/demo', '/design-system', '/developers', '/docs', '/dpa', '/fsma', '/fsma-204', '/integrations', '/legal', '/pricing', '/privacy', '/product', '/proof', '/retailer-readiness', - '/sandbox', '/security', '/status', '/supplier-compliance', '/terms', + '/security', '/status', '/supplier-compliance', '/terms', '/tools', '/trust', '/trust-center', '/verify', '/verticals', '/walkthrough', '/why-regengine', ]); diff --git a/frontend/src/lib/sandbox-cta.ts b/frontend/src/lib/sandbox-cta.ts index ffc56e9e6..cf4587626 100644 --- a/frontend/src/lib/sandbox-cta.ts +++ b/frontend/src/lib/sandbox-cta.ts @@ -37,15 +37,8 @@ export function isSandboxEnabled(): boolean { } export function sandboxCta(): SandboxCta { - if (isSandboxEnabled()) { - return { - enabled: true, - href: '/sandbox', - label: 'Try the Sandbox', - blurb: - 'Drop your CSV in the sandbox and get FSMA 204 compliance results in under 60 seconds. No signup required.', - }; - } + // The public /sandbox route was retired. The CTA never links to it, even + // when the experimental sandbox router is enabled on the backend. return { enabled: false, href: '/demo', diff --git a/frontend/tests/e2e/sandbox-front-door.spec.ts b/frontend/tests/e2e/sandbox-front-door.spec.ts deleted file mode 100644 index 8aa8bc4c4..000000000 --- a/frontend/tests/e2e/sandbox-front-door.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Sandbox front door E2E — #2757. - * - * Walks the funnel the compare pages advertise: comparison CTA → sandbox → - * rendered FSMA 204 diagnosis for a sample FTL chain. - * - * The sandbox evaluator is an experimental router (server/main.py - * _EXPERIMENTAL_ROUTERS) and is auto-disabled in production, so this spec - * probes it at runtime and asserts the correct behaviour for whichever state - * the target deployment is in: - * - * mounted → CTA lands on /sandbox and the sample CSV evaluates end to end. - * unmounted → CTA lands on /demo, no page promises a CSV sandbox, and a - * direct visit to /sandbox explains itself instead of 404ing. - * - * Either way the invariant under test is the one #2730 broke: CTA copy and CTA - * destination must agree. - */ -import { expect, test, type Page } from '@playwright/test'; - -const COMPARE_PAGES = ['/compare/tracegains', '/compare/foodlogiq', '/compare/repositrak']; - -const SANDBOX_PROMISE = /drop your csv in the sandbox/i; - -async function sandboxEvaluatorMounted(page: Page): Promise { - const res = await page.request.post('/api/ingestion/api/v1/sandbox/evaluate', { - data: { csv: 'cte_type\nshipping\n' }, - failOnStatusCode: false, - }); - // 404 = router not mounted on this deployment. Anything else (200, 400, - // 429) means the endpoint is there and answering. - return res.status() !== 404; -} - -test.describe('sandbox front door (#2757)', () => { - test('/sandbox is a real route, never a 404', async ({ page }) => { - const res = await page.goto('/sandbox'); - expect(res?.status()).toBe(200); - // Whichever state we are in, the page says something specific. - await expect( - page.getByRole('heading', { name: /FSMA 204 Sandbox|not enabled on this deployment/i }), - ).toBeVisible(); - }); - - for (const comparePath of COMPARE_PAGES) { - test(`${comparePath} CTA copy matches where it goes`, async ({ page }) => { - const mounted = await sandboxEvaluatorMounted(page); - await page.goto(comparePath); - - const cta = page - .getByRole('link', { name: mounted ? /Try the Sandbox/i : /interactive demo/i }) - .first(); - await expect(cta).toBeVisible(); - - const body = await page.locator('body').innerText(); - if (mounted) { - expect(body).toMatch(SANDBOX_PROMISE); - } else { - // The #2730 failure: sandbox prose above a button going elsewhere. - expect(body).not.toMatch(SANDBOX_PROMISE); - } - - await cta.click(); - await expect(page).toHaveURL(new RegExp(mounted ? '/sandbox$' : '/demo')); - }); - } - - test('homepage #sandbox anchor resolves to the same destination', async ({ page }) => { - const mounted = await sandboxEvaluatorMounted(page); - await page.goto('/#sandbox'); - - const section = page.locator('#sandbox'); - await expect(section).toBeVisible(); - - const cta = section.getByRole('link').first(); - await cta.click(); - await expect(page).toHaveURL(new RegExp(mounted ? '/sandbox$' : '/demo')); - }); - - test('sample FTL CSV evaluates and renders a diagnosis', async ({ page }) => { - test.skip( - !(await sandboxEvaluatorMounted(page)), - 'sandbox router not mounted on this deployment (experimental, prod-disabled)', - ); - - await page.goto('/sandbox'); - await page.getByRole('button', { name: /Run a sample FTL chain/i }).click(); - - await expect(page.getByText('FSMA 204 Sandbox Diagnosis')).toBeVisible({ timeout: 30_000 }); - - // The sample chain is deliberately imperfect — a clean result would mean - // the rules did not run. - await expect(page.getByText('SV-20260115-001').first()).toBeVisible(); - await expect(page.getByText(/need attention|NEEDS CORRECTION/i).first()).toBeVisible(); - }); - - test('unmounted evaluator explains itself instead of hanging', async ({ page }) => { - test.skip( - await sandboxEvaluatorMounted(page), - 'sandbox router is mounted — the unavailable path does not apply', - ); - - await page.goto('/sandbox'); - await expect(page.getByText(/not enabled on this deployment/i)).toBeVisible(); - await expect(page.getByRole('link', { name: /interactive demo/i })).toBeVisible(); - }); -}); From 059da16c25823936da436a98b95c9a17ff386087 Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Fri, 23 Oct 2026 19:05:51 -0700 Subject: [PATCH 2/3] sandboxCta: fall back to /ingest CSV upload instead of /demo --- frontend/src/__tests__/lib/sandbox-cta.test.ts | 11 +++++++++++ frontend/src/lib/sandbox-cta.ts | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 frontend/src/__tests__/lib/sandbox-cta.test.ts diff --git a/frontend/src/__tests__/lib/sandbox-cta.test.ts b/frontend/src/__tests__/lib/sandbox-cta.test.ts new file mode 100644 index 000000000..26da526ad --- /dev/null +++ b/frontend/src/__tests__/lib/sandbox-cta.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { sandboxCta } from '@/lib/sandbox-cta'; + +describe('sandboxCta', () => { + it('points at the CSV upload route, never the retired /sandbox or /demo', () => { + const cta = sandboxCta(); + expect(cta.href).toBe('/ingest'); + expect(cta.href).not.toBe('/sandbox'); + expect(cta.href).not.toBe('/demo'); + }); +}); diff --git a/frontend/src/lib/sandbox-cta.ts b/frontend/src/lib/sandbox-cta.ts index cf4587626..6c849c76d 100644 --- a/frontend/src/lib/sandbox-cta.ts +++ b/frontend/src/lib/sandbox-cta.ts @@ -41,9 +41,9 @@ export function sandboxCta(): SandboxCta { // when the experimental sandbox router is enabled on the backend. return { enabled: false, - href: '/demo', - label: 'Explore the interactive demo', + href: '/ingest', + label: 'Upload your CSV', blurb: - 'Walk through a synthetic FSMA 204 evidence-layer demo. No signup required.', + 'Upload your own traceability records as CSV to check them against FSMA 204.', }; } From b63b70817b4d2edc7ef52654ff62f8f6e72328d2 Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Fri, 23 Oct 2026 20:18:40 -0700 Subject: [PATCH 3/3] chore(frontend): remove sandbox-only result view and PDF report left unused after /sandbox removal Signed-off-by: Christopher Sellers <1@christophersellers.com> --- .../sandbox-grid/SandboxPdfReport.ts | 347 ------------------ .../sandbox-grid/SandboxResultView.tsx | 309 ---------------- 2 files changed, 656 deletions(-) delete mode 100644 frontend/src/components/marketing/sandbox-grid/SandboxPdfReport.ts delete mode 100644 frontend/src/components/marketing/sandbox-grid/SandboxResultView.tsx diff --git a/frontend/src/components/marketing/sandbox-grid/SandboxPdfReport.ts b/frontend/src/components/marketing/sandbox-grid/SandboxPdfReport.ts deleted file mode 100644 index 029a88c5c..000000000 --- a/frontend/src/components/marketing/sandbox-grid/SandboxPdfReport.ts +++ /dev/null @@ -1,347 +0,0 @@ -/** - * Branded PDF compliance report generator using jsPDF + jspdf-autotable. - * - * Generates a professional FSMA 204 compliance assessment PDF with: - * - Executive summary with compliance score - * - Detailed findings table (failures + warnings) - * - Normalization diff (if available) - * - RegEngine branding and call-to-action - */ - -import jsPDF from 'jspdf'; -import autoTable from 'jspdf-autotable'; - -// --------------------------------------------------------------------------- -// Types (mirrors SandboxUpload interfaces) -// --------------------------------------------------------------------------- - -interface RuleResult { - rule_title: string; - severity: string; - result: string; - why_failed: string | null; - citation: string | null; - remediation: string | null; - category: string; -} - -interface EventEvaluation { - event_index: number; - cte_type: string; - traceability_lot_code: string; - product_description: string; - kde_errors: string[]; - rules_evaluated: number; - rules_passed: number; - rules_failed: number; - rules_warned: number; - compliant: boolean; - all_results: RuleResult[]; -} - -interface NormalizationAction { - field: string; - original: string; - normalized: string; - action_type: string; -} - -interface SandboxResult { - total_events: number; - compliant_events: number; - non_compliant_events: number; - total_kde_errors: number; - total_rule_failures: number; - submission_blocked: boolean; - blocking_reasons: string[]; - events: EventEvaluation[]; - normalizations?: NormalizationAction[]; -} - -// --------------------------------------------------------------------------- -// Colors -// --------------------------------------------------------------------------- - -const BRAND = { r: 79, g: 70, b: 229 }; // indigo-600 -const DANGER = { r: 220, g: 38, b: 38 }; // red-600 -const WARNING = { r: 217, g: 119, b: 6 }; // amber-600 -const SUCCESS = { r: 22, g: 163, b: 74 }; // green-600 -const GRAY = { r: 107, g: 114, b: 128 }; // gray-500 -const LIGHT_GRAY = { r: 243, g: 244, b: 246 };// gray-100 - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function pct(numerator: number, denominator: number): string { - if (denominator === 0) return '0%'; - return `${Math.round((numerator / denominator) * 100)}%`; -} - -function truncate(str: string, max: number): string { - if (str.length <= max) return str; - return str.slice(0, max - 1) + '\u2026'; -} - -// --------------------------------------------------------------------------- -// Main export -// --------------------------------------------------------------------------- - -export function generateComplianceReport(result: SandboxResult): void { - const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); - const pageWidth = doc.internal.pageSize.getWidth(); - const margin = 15; - const contentWidth = pageWidth - margin * 2; - const now = new Date().toLocaleString(); - let y = margin; - - // ---- Header bar ---- - doc.setFillColor(BRAND.r, BRAND.g, BRAND.b); - doc.rect(0, 0, pageWidth, 22, 'F'); - doc.setFont('helvetica', 'bold'); - doc.setFontSize(16); - doc.setTextColor(255, 255, 255); - doc.text('RegEngine', margin, 14); - doc.setFont('helvetica', 'normal'); - doc.setFontSize(10); - doc.text('FSMA 204 Sandbox Assessment', pageWidth - margin, 10, { align: 'right' }); - doc.setFontSize(8); - doc.text(now, pageWidth - margin, 16, { align: 'right' }); - y = 30; - - // ---- Compliance score ---- - const score = pct(result.compliant_events, result.total_events); - const scoreColor = result.compliant_events === result.total_events ? SUCCESS - : result.non_compliant_events > result.total_events / 2 ? DANGER : WARNING; - - doc.setFontSize(28); - doc.setFont('helvetica', 'bold'); - doc.setTextColor(scoreColor.r, scoreColor.g, scoreColor.b); - doc.text(score, margin, y + 10); - - doc.setFontSize(10); - doc.setTextColor(GRAY.r, GRAY.g, GRAY.b); - doc.text('Sandbox Pass Rate', margin + 32, y + 4); - doc.setFont('helvetica', 'normal'); - doc.setFontSize(9); - doc.text( - `${result.compliant_events} of ${result.total_events} events passed sandbox checks`, - margin + 32, y + 10, - ); - y += 20; - - // ---- Summary stats boxes ---- - const boxWidth = (contentWidth - 9) / 4; - const stats = [ - { label: 'Total Events', value: String(result.total_events), color: GRAY }, - { label: 'Passed Checks', value: String(result.compliant_events), color: SUCCESS }, - { label: 'Need Work', value: String(result.non_compliant_events), color: DANGER }, - { label: 'Rule Issues', value: String(result.total_rule_failures), color: WARNING }, - ]; - - for (let i = 0; i < stats.length; i++) { - const x = margin + i * (boxWidth + 3); - doc.setFillColor(LIGHT_GRAY.r, LIGHT_GRAY.g, LIGHT_GRAY.b); - doc.roundedRect(x, y, boxWidth, 18, 2, 2, 'F'); - doc.setFont('helvetica', 'bold'); - doc.setFontSize(14); - doc.setTextColor(stats[i].color.r, stats[i].color.g, stats[i].color.b); - doc.text(stats[i].value, x + boxWidth / 2, y + 9, { align: 'center' }); - doc.setFont('helvetica', 'normal'); - doc.setFontSize(7); - doc.setTextColor(GRAY.r, GRAY.g, GRAY.b); - doc.text(stats[i].label, x + boxWidth / 2, y + 15, { align: 'center' }); - } - y += 24; - - // ---- Blocking banner ---- - if (result.submission_blocked) { - doc.setFillColor(254, 242, 242); - doc.setDrawColor(DANGER.r, DANGER.g, DANGER.b); - doc.roundedRect(margin, y, contentWidth, 14, 2, 2, 'FD'); - doc.setFont('helvetica', 'bold'); - doc.setFontSize(9); - doc.setTextColor(DANGER.r, DANGER.g, DANGER.b); - doc.text( - `IMPORT MAPPING NEEDS CORRECTION: ${result.blocking_reasons.length} blocker${result.blocking_reasons.length !== 1 ? 's' : ''}`, - margin + 4, y + 6, - ); - doc.setFont('helvetica', 'normal'); - doc.setFontSize(7); - const topReasons = result.blocking_reasons.slice(0, 3).join('; '); - doc.text(truncate(topReasons, 120), margin + 4, y + 11); - y += 18; - } - - // ---- Detailed findings table ---- - const findings: string[][] = []; - for (const ev of result.events) { - // KDE errors - for (const err of ev.kde_errors) { - findings.push([ - String(ev.event_index + 1), - truncate(ev.traceability_lot_code, 18), - ev.cte_type, - 'KDE Error', - truncate(err, 60), - '', - ]); - } - // Rule failures and warnings - for (const r of ev.all_results) { - if (r.result !== 'fail' && r.result !== 'warn') continue; - findings.push([ - String(ev.event_index + 1), - truncate(ev.traceability_lot_code, 18), - ev.cte_type, - r.severity === 'critical' ? 'Critical' : r.result === 'warn' ? 'Warning' : 'Failure', - truncate(r.rule_title, 40), - r.citation ? truncate(r.citation, 20) : '', - ]); - } - } - - if (findings.length > 0) { - doc.setFont('helvetica', 'bold'); - doc.setFontSize(11); - doc.setTextColor(30, 30, 30); - doc.text('Detailed Findings', margin, y + 6); - y += 10; - - autoTable(doc, { - startY: y, - head: [['#', 'Lot Code', 'CTE Type', 'Severity', 'Issue', 'Citation']], - body: findings, - margin: { left: margin, right: margin }, - styles: { fontSize: 7, cellPadding: 2 }, - headStyles: { - fillColor: [BRAND.r, BRAND.g, BRAND.b], - textColor: 255, - fontStyle: 'bold', - }, - columnStyles: { - 0: { cellWidth: 8 }, - 1: { cellWidth: 28 }, - 2: { cellWidth: 24 }, - 3: { cellWidth: 18 }, - 4: { cellWidth: 'auto' }, - 5: { cellWidth: 28 }, - }, - didParseCell(data) { - if (data.section === 'body' && data.column.index === 3) { - const val = String(data.cell.raw); - if (val === 'Critical' || val === 'KDE Error') { - data.cell.styles.textColor = [DANGER.r, DANGER.g, DANGER.b]; - data.cell.styles.fontStyle = 'bold'; - } else if (val === 'Warning') { - data.cell.styles.textColor = [WARNING.r, WARNING.g, WARNING.b]; - } - } - }, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - y = (doc as any).lastAutoTable.finalY + 6; - } - - // ---- Normalization diff ---- - if (result.normalizations && result.normalizations.length > 0) { - // Check if we need a new page - if (y > 240) { - doc.addPage(); - y = margin; - } - - doc.setFont('helvetica', 'bold'); - doc.setFontSize(11); - doc.setTextColor(30, 30, 30); - doc.text('What RegEngine Normalized', margin, y + 6); - y += 10; - - const normRows = result.normalizations.map((n) => [ - n.action_type === 'header_alias' ? 'Header' - : n.action_type === 'uom_normalize' ? 'Unit' - : n.action_type === 'cte_type_normalize' ? 'CTE Type' - : n.action_type, - n.original, - '\u2192', - n.normalized, - n.field, - ]); - - autoTable(doc, { - startY: y, - head: [['Type', 'Your Data', '', 'Cleaned', 'Field']], - body: normRows, - margin: { left: margin, right: margin }, - styles: { fontSize: 7, cellPadding: 2 }, - headStyles: { - fillColor: [BRAND.r, BRAND.g, BRAND.b], - textColor: 255, - fontStyle: 'bold', - }, - columnStyles: { - 0: { cellWidth: 18 }, - 1: { cellWidth: 40 }, - 2: { cellWidth: 8, halign: 'center' }, - 3: { cellWidth: 40 }, - 4: { cellWidth: 'auto' }, - }, - didParseCell(data) { - if (data.section === 'body') { - if (data.column.index === 1) { - data.cell.styles.textColor = [DANGER.r, DANGER.g, DANGER.b]; - data.cell.styles.fontStyle = 'bold'; - } else if (data.column.index === 3) { - data.cell.styles.textColor = [SUCCESS.r, SUCCESS.g, SUCCESS.b]; - data.cell.styles.fontStyle = 'bold'; - } - } - }, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - y = (doc as any).lastAutoTable.finalY + 6; - } - - // ---- Passed events summary ---- - const passedEvents = result.events.filter((ev) => ev.compliant); - if (passedEvents.length > 0 && passedEvents.length < result.total_events) { - if (y > 250) { - doc.addPage(); - y = margin; - } - - doc.setFont('helvetica', 'normal'); - doc.setFontSize(8); - doc.setTextColor(SUCCESS.r, SUCCESS.g, SUCCESS.b); - const passedTlcs = passedEvents.slice(0, 10).map((e) => e.traceability_lot_code).join(', '); - doc.text( - `\u2713 ${passedEvents.length} event${passedEvents.length !== 1 ? 's' : ''} passed sandbox checks: ${truncate(passedTlcs, 100)}`, - margin, y, - ); - y += 8; - } - - // ---- Footer on every page ---- - const totalPages = doc.getNumberOfPages(); - for (let p = 1; p <= totalPages; p++) { - doc.setPage(p); - const pageH = doc.internal.pageSize.getHeight(); - doc.setDrawColor(BRAND.r, BRAND.g, BRAND.b); - doc.line(margin, pageH - 14, pageWidth - margin, pageH - 14); - doc.setFont('helvetica', 'normal'); - doc.setFontSize(7); - doc.setTextColor(GRAY.r, GRAY.g, GRAY.b); - doc.text('Generated by RegEngine (www.regengine.co)', margin, pageH - 9); - doc.text(`Page ${p} of ${totalPages}`, pageWidth - margin, pageH - 9, { align: 'right' }); - doc.setFontSize(7); - doc.setTextColor(BRAND.r, BRAND.g, BRAND.b); - doc.text( - 'Ready to improve traceability workflows? Visit regengine.co/demo', - pageWidth / 2, pageH - 9, { align: 'center' }, - ); - } - - doc.save('RegEngine-FSMA204-Report.pdf'); -} diff --git a/frontend/src/components/marketing/sandbox-grid/SandboxResultView.tsx b/frontend/src/components/marketing/sandbox-grid/SandboxResultView.tsx deleted file mode 100644 index 9baac9b74..000000000 --- a/frontend/src/components/marketing/sandbox-grid/SandboxResultView.tsx +++ /dev/null @@ -1,309 +0,0 @@ -'use client'; - -// #2757 - extracted verbatim from the shared-result page so /sandbox and -// /sandbox/results/[id] render one diagnosis, not two that drift apart. -// Presentational only: no fetching, no analytics. Callers own those. - -import { - AlertTriangle, CheckCircle2, XCircle, ShieldAlert, - ChevronDown, ChevronUp, Download, Sparkles, ArrowRight, -} from 'lucide-react'; -import { useState, type ReactNode } from 'react'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Callout } from '@/components/ui/callout'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { StatTile } from '@/components/ui/stat-tile'; -import { generateComplianceReport } from '@/components/marketing/sandbox-grid/SandboxPdfReport'; - -// --------------------------------------------------------------------------- -// Types (mirror services/ingestion/app/sandbox/models.py) -// --------------------------------------------------------------------------- - -export interface RuleResult { - rule_title: string; - severity: string; - result: string; - why_failed: string | null; - citation: string | null; - remediation: string | null; - category: string; -} - -export interface EventEvaluation { - event_index: number; - cte_type: string; - traceability_lot_code: string; - product_description: string; - kde_errors: string[]; - rules_evaluated: number; - rules_passed: number; - rules_failed: number; - rules_warned: number; - compliant: boolean; - blocking_defects: RuleResult[]; - all_results: RuleResult[]; -} - -export interface NormalizationAction { - field: string; - original: string; - normalized: string; - action_type: string; -} - -export interface SandboxResult { - total_events: number; - compliant_events: number; - non_compliant_events: number; - total_kde_errors: number; - total_rule_failures: number; - submission_blocked: boolean; - blocking_reasons: string[]; - events: EventEvaluation[]; - duplicate_warnings?: string[]; - entity_warnings?: string[]; - normalizations?: NormalizationAction[]; -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function SandboxResultView({ - result, - footer, -}: { - result: SandboxResult; - /** CTA slot rendered below the per-event list. */ - footer?: ReactNode; -}) { - const [expandedEvents, setExpandedEvents] = useState>(() => { - const nonCompliant = new Set(); - result.events.forEach((ev) => { - if (!ev.compliant) nonCompliant.add(ev.event_index); - }); - return nonCompliant; - }); - - function toggleEvent(index: number) { - setExpandedEvents((prev) => { - const next = new Set(prev); - if (next.has(index)) next.delete(index); - else next.add(index); - return next; - }); - } - - return ( - - - FSMA 204 Sandbox Diagnosis - - - - - {/* Summary Bar */} -
- - - - -
- - {/* Normalization Diff */} - {result.normalizations && result.normalizations.length > 0 && ( -
- - - {expandedEvents.has(-1) && ( -
- {(() => { - const grouped: Record = {}; - for (const n of result.normalizations!) { - const label = n.action_type === 'header_alias' ? 'Headers Mapped' - : n.action_type === 'uom_normalize' ? 'Units Standardized' - : n.action_type === 'cte_type_normalize' ? 'CTE Types Resolved' - : 'Other'; - (grouped[label] ??= []).push(n); - } - return Object.entries(grouped).map(([label, items]) => ( -
-
- {label} -
-
- {items.map((n, i) => ( -
- - {n.original} - -
- ))} -
-
- )); - })()} -
- )} -
- )} - - {/* Status Banner */} - {result.submission_blocked && ( - -

- TEST RUN NEEDS CORRECTION: {result.blocking_reasons.length} blocker{result.blocking_reasons.length !== 1 ? 's' : ''} -

-
    - {result.blocking_reasons.slice(0, 10).map((reason, i) => ( -
  • -
  • - ))} -
-
- )} - - {!result.submission_blocked && result.non_compliant_events === 0 && ( - -

- SANDBOX CHECKS CLEAR: ready to save as a test run -

-
- )} - - {!result.submission_blocked && result.non_compliant_events > 0 && ( - -

- {result.non_compliant_events} event{result.non_compliant_events !== 1 ? 's' : ''} need attention -

-
- )} - - {/* Per-Event Results */} -
- {result.events.map((ev) => ( -
- - - {expandedEvents.has(ev.event_index) && ( -
- {ev.kde_errors.length > 0 && ( -
- KDE Validation Errors: - {ev.kde_errors.map((err, j) => ( -
-
- ))} -
- )} - -
- {ev.all_results - .filter((r) => r.result === 'fail' || r.result === 'warn') - .map((r, j) => ( -
- {r.result === 'fail' - ?
- ))} - {ev.all_results.filter((r) => r.result === 'pass').length > 0 && ( -
-
- )} -
-
- )} -
- ))} -
- - {footer} -
-
- ); -}