Step vocabulary gains `waitFor`: block until a selector exists, then continue. Nothing is clicked or typed — it is a gate for conditions something outside the run has to satisfy. Unlike every other step it carries no signed-out guard, because the things worth gating on often sit on the login page, where that guard would abort the run at exactly the wrong moment. Honours the dashboard's Stop button, since a two-minute gate that ignored it would be worse than no gate. Session handling. A run that lands on the login page must not continue: once redirected, every selector resolves against a login form, so a click aimed at "Add Account" hits whatever that form renders in the same place. Runs now detect the redirect and stop before sending any input, with a distinct SignedOutError rather than a generic failure. Two ways out of that state, in order: a firm's `authSteps` run and the failed step is retried, or — when none are defined — the run pauses for signedOutWaitSeconds so a human can sign in, then resumes. Auth steps are verified rather than trusted: they can all "succeed" while the site still rejects the sign-in, so the session is re-checked before the retry, and the run stops with "auth steps ran but the session is still signed out" if it did not take. That check polls for up to 20s instead of reading once. Submitting a login form starts a network round trip and then a redirect, so the tab still shows the login URL for a second or two afterwards; checking immediately failed a sign-in that was merely in flight, killing run #15 nine seconds after it had actually worked. Third instance of the same mistake in this system — reading page state immediately after an action that triggers async navigation. The runner reports its version in the heartbeat and the dashboard blocks the buttons when it is behind. A running Python process does not reload when the source changes, so a stale runner fails on step types it predates; that cost a debugging round when a navigate step reached a runner that had never heard of one. lib/automations.ts carries the Tradeify buy-accounts flow: navigate to the dashboard, open Add Account, pick the account type and size, enter the account name, and work through the challenge widget before submitting. Selectors are authored by hand against the live page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
549 lines
27 KiB
TypeScript
549 lines
27 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
|
||
interface ElementPosition {
|
||
index: number;
|
||
tag: string;
|
||
id: string | null;
|
||
text: string;
|
||
visible: boolean;
|
||
inViewport: boolean;
|
||
viewport: { x: number; y: number; width: number; height: number };
|
||
page: { x: number; y: number };
|
||
screen: { x: number; y: number };
|
||
}
|
||
|
||
interface ViewportMetrics {
|
||
scrollX: number;
|
||
scrollY: number;
|
||
innerWidth: number;
|
||
innerHeight: number;
|
||
screenX: number;
|
||
screenY: number;
|
||
chromeHeight: number;
|
||
devicePixelRatio: number;
|
||
}
|
||
|
||
interface Capture {
|
||
url: string;
|
||
title: string;
|
||
html: string;
|
||
viewport: Partial<ViewportMetrics>;
|
||
elements: ElementPosition[];
|
||
capturedAt: number;
|
||
}
|
||
|
||
interface AutomationSummary {
|
||
/** firm:automation — what runs are recorded against. */
|
||
key: string;
|
||
id: string;
|
||
label: string;
|
||
description: string;
|
||
confirm: string | null;
|
||
steps: string[];
|
||
}
|
||
|
||
interface FirmSummary {
|
||
id: string;
|
||
label: string;
|
||
urlPattern: string;
|
||
automations: AutomationSummary[];
|
||
}
|
||
|
||
interface RunLogEntry {
|
||
at: number;
|
||
step: string;
|
||
ok: boolean;
|
||
detail?: string;
|
||
}
|
||
|
||
interface Run {
|
||
id: number;
|
||
automationId: string;
|
||
automationLabel: string;
|
||
status: 'queued' | 'running' | 'done' | 'error' | 'cancelled';
|
||
stepIndex: number;
|
||
totalSteps: number;
|
||
log: RunLogEntry[];
|
||
error: string | null;
|
||
createdAt: number;
|
||
updatedAt: number;
|
||
}
|
||
|
||
interface RunnerStatus {
|
||
online: boolean;
|
||
lastSeen: number | null;
|
||
ageMs: number | null;
|
||
host?: string | null;
|
||
pid?: number | null;
|
||
dryRun?: boolean;
|
||
busy?: boolean;
|
||
version?: string | null;
|
||
expectedVersion?: string;
|
||
stale?: boolean;
|
||
}
|
||
|
||
const RUN_BADGE: Record<Run['status'], string> = {
|
||
queued: 'bg-slate-100 text-slate-600',
|
||
running: 'bg-blue-100 text-blue-700',
|
||
done: 'bg-green-100 text-green-700',
|
||
error: 'bg-red-100 text-red-700',
|
||
cancelled: 'bg-amber-100 text-amber-700',
|
||
};
|
||
|
||
const POLL_MS = 2000;
|
||
|
||
/** Point relative URLs (stylesheets, images, fonts) at the origin the snapshot came
|
||
* from, otherwise the render comes out unstyled. The HTML parser hoists a leading
|
||
* <base> into <head>, so prepending works even if the markup has no explicit head. */
|
||
function withBaseTag(html: string, url: string): string {
|
||
const base = `<base href="${url.replace(/"/g, '"')}">`;
|
||
const head = html.match(/<head\b[^>]*>/i);
|
||
if (!head || head.index === undefined) return base + html;
|
||
const at = head.index + head[0].length;
|
||
return html.slice(0, at) + base + html.slice(at);
|
||
}
|
||
|
||
export default function AutoBuyer() {
|
||
const [enabled, setEnabled] = useState(false);
|
||
const [capture, setCapture] = useState<Capture | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [view, setView] = useState<'rendered' | 'source'>('source');
|
||
// The extension posts a new capture every few seconds, and re-feeding srcDoc would
|
||
// reload the frame that often. So the rendered view shows a snapshot pinned when you
|
||
// opened it — clicking Rendered again re-pins to the latest capture.
|
||
const [pinned, setPinned] = useState<Capture | null>(null);
|
||
const [firms, setFirms] = useState<FirmSummary[]>([]);
|
||
const [activeFirm, setActiveFirm] = useState<string | null>(null);
|
||
const [runs, setRuns] = useState<Run[]>([]);
|
||
const [busyRun, setBusyRun] = useState(false);
|
||
const [runner, setRunner] = useState<RunnerStatus | null>(null);
|
||
|
||
// Tracks the newest capture we already hold, so the poll can skip re-downloading it.
|
||
const lastAtRef = useRef(0);
|
||
|
||
const poll = useCallback(async () => {
|
||
try {
|
||
const s = await fetch('/api/autobuyer/status', { cache: 'no-store' }).then((r) => r.json());
|
||
setEnabled(!!s.enabled);
|
||
if (!s.enabled) return;
|
||
|
||
const url = lastAtRef.current
|
||
? `/api/autobuyer/capture?since=${lastAtRef.current}`
|
||
: '/api/autobuyer/capture';
|
||
const data = await fetch(url, { cache: 'no-store' }).then((r) => r.json());
|
||
if (data.unchanged || !data.capture) return;
|
||
|
||
lastAtRef.current = data.capture.capturedAt;
|
||
setCapture(data.capture);
|
||
setError(null);
|
||
} catch (err: any) {
|
||
setError(err?.message ?? 'Poll failed');
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
poll();
|
||
const id = setInterval(poll, POLL_MS);
|
||
return () => clearInterval(id);
|
||
}, [poll]);
|
||
|
||
useEffect(() => {
|
||
fetch('/api/autobuyer/automations')
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
const list: FirmSummary[] = d.firms ?? [];
|
||
setFirms(list);
|
||
setActiveFirm((current) => current ?? list[0]?.id ?? null);
|
||
})
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
// Runs tick faster than the capture poll — a run is something you watch.
|
||
const pollRuns = useCallback(async () => {
|
||
try {
|
||
const [d, r] = await Promise.all([
|
||
fetch('/api/autobuyer/runs', { cache: 'no-store' }).then((x) => x.json()),
|
||
fetch('/api/autobuyer/runner', { cache: 'no-store' }).then((x) => x.json()),
|
||
]);
|
||
setRuns(d.runs ?? []);
|
||
setRunner(r);
|
||
} catch { /* transient; the next tick retries */ }
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
pollRuns();
|
||
const id = setInterval(pollRuns, 1000);
|
||
return () => clearInterval(id);
|
||
}, [pollRuns]);
|
||
|
||
const activeRun = runs.find((r) => r.status === 'queued' || r.status === 'running') ?? null;
|
||
const firmAutomations = firms.find((f) => f.id === activeFirm)?.automations ?? [];
|
||
|
||
async function startRun(automation: AutomationSummary) {
|
||
// Anything that drives the real mouse against a broker gets a confirmation.
|
||
if (automation.confirm && !window.confirm(
|
||
`${automation.confirm}\n\nSteps:\n${automation.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
|
||
)) return;
|
||
|
||
setBusyRun(true);
|
||
try {
|
||
const res = await fetch('/api/autobuyer/automations', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ automationId: automation.key }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) setError(data.error ?? 'Failed to start');
|
||
else setError(null);
|
||
await pollRuns();
|
||
} catch (err: any) {
|
||
setError(err?.message ?? 'Failed to start');
|
||
} finally {
|
||
setBusyRun(false);
|
||
}
|
||
}
|
||
|
||
async function cancelRun(id: number) {
|
||
await fetch(`/api/autobuyer/runs?id=${id}`, { method: 'DELETE' });
|
||
await pollRuns();
|
||
}
|
||
|
||
async function toggle() {
|
||
setBusy(true);
|
||
try {
|
||
const res = await fetch('/api/autobuyer/status', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: !enabled }),
|
||
});
|
||
const data = await res.json();
|
||
setEnabled(!!data.enabled);
|
||
setError(null);
|
||
} catch (err: any) {
|
||
setError(err?.message ?? 'Failed to toggle');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function showRendered() {
|
||
setView('rendered');
|
||
if (capture) setPinned(capture);
|
||
}
|
||
|
||
async function clearCapture() {
|
||
await fetch('/api/autobuyer/capture', { method: 'DELETE' });
|
||
lastAtRef.current = 0;
|
||
setCapture(null);
|
||
setPinned(null);
|
||
}
|
||
|
||
const stale = capture ? Date.now() - capture.capturedAt > 15000 : false;
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 p-8">
|
||
<div className="max-w-7xl mx-auto">
|
||
<h1 className="text-2xl font-bold text-slate-900 mb-6">AutoBuyer</h1>
|
||
|
||
{/* ── Capture switch ── */}
|
||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-6">
|
||
<div className="flex items-center justify-between px-4 py-3">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800">Page capture</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
While on, the AutoFirmer Capture extension posts the target tab's HTML here every few seconds
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={toggle}
|
||
disabled={busy}
|
||
className={`text-sm px-4 py-1.5 rounded-lg font-medium transition-colors disabled:opacity-50 ${
|
||
enabled
|
||
? 'bg-green-500 hover:bg-green-600 text-white'
|
||
: 'bg-slate-200 hover:bg-slate-300 text-slate-700'
|
||
}`}
|
||
>
|
||
{busy ? '…' : enabled ? 'ON' : 'OFF'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── One tab per firm ── */}
|
||
<div className="flex items-end gap-5 mb-3 border-b border-slate-200">
|
||
{firms.map((firm) => (
|
||
<button
|
||
key={firm.id}
|
||
onClick={() => setActiveFirm(firm.id)}
|
||
className={`text-sm font-semibold uppercase tracking-wider pb-2 -mb-px border-b-2 transition-colors ${
|
||
firm.id === activeFirm
|
||
? 'text-slate-900 border-slate-900'
|
||
: 'text-slate-400 border-transparent hover:text-slate-600'
|
||
}`}
|
||
>
|
||
{firm.label}
|
||
</button>
|
||
))}
|
||
<span className="ml-auto flex items-center gap-1.5 text-xs pb-2">
|
||
<span className={`w-2 h-2 rounded-full ${
|
||
runner?.online ? (runner.busy ? 'bg-blue-500 animate-pulse' : 'bg-green-500') : 'bg-slate-300'
|
||
}`} />
|
||
<span className={runner?.online ? 'text-slate-500' : 'text-slate-400'}>
|
||
{!runner?.online
|
||
? 'Runner offline'
|
||
: runner.busy ? 'Runner busy' : 'Runner online'}
|
||
</span>
|
||
{runner?.online && runner.dryRun && (
|
||
<span className="ml-1 px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">
|
||
dry run
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
{!runner?.online && (
|
||
<div className="mb-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-2.5 text-xs text-slate-600">
|
||
Nothing will run until the runner is started — it's the process that
|
||
moves the mouse. In the project directory:{' '}
|
||
<code className="font-mono text-slate-800">python clicker/runner.py</code>
|
||
</div>
|
||
)}
|
||
|
||
{runner?.online && runner.stale && (
|
||
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-xs text-red-800">
|
||
The runner is out of date (reporting v{runner.version ?? 'unknown'}, this
|
||
dashboard needs v{runner.expectedVersion}). It will fail on step types it
|
||
predates. Restart it: stop with Ctrl-C and run{' '}
|
||
<code className="font-mono">python clicker/runner.py</code> again.
|
||
</div>
|
||
)}
|
||
|
||
{runner?.online && runner.dryRun && (
|
||
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs text-amber-800">
|
||
The runner is in dry-run mode: it will walk through the steps and move the
|
||
cursor, but never press or type. Restart it without <code className="font-mono">--dry-run</code> to act for real.
|
||
</div>
|
||
)}
|
||
|
||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
|
||
{firmAutomations.length === 0 ? (
|
||
<div className="px-4 py-6 text-center text-sm text-slate-400">
|
||
{firms.length === 0 ? 'No firms configured' : 'No automations for this firm yet'}
|
||
</div>
|
||
) : firmAutomations.map((a, i) => (
|
||
<div
|
||
key={a.key}
|
||
className={`flex items-center justify-between px-4 py-3 ${
|
||
i < firmAutomations.length - 1 ? 'border-b border-slate-100' : ''
|
||
}`}
|
||
>
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800">{a.label}</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
{a.description} · {a.steps.length} step{a.steps.length === 1 ? '' : 's'}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => startRun(a)}
|
||
disabled={!enabled || busyRun || activeRun !== null || !runner?.online || !!runner?.stale}
|
||
title={
|
||
!enabled ? 'AutoBuyer is switched off'
|
||
: !runner?.online ? 'The runner is not running'
|
||
: runner?.stale ? 'The runner is out of date — restart it'
|
||
: activeRun !== null ? 'A run is already in progress'
|
||
: undefined
|
||
}
|
||
className="text-sm px-4 py-1.5 rounded-lg font-medium bg-blue-500 hover:bg-blue-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{a.label}
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Run status ── */}
|
||
{runs.length > 0 && (
|
||
<>
|
||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||
Runs
|
||
</h2>
|
||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8 divide-y divide-slate-100">
|
||
{runs.map((run) => (
|
||
<div key={run.id} className="px-4 py-3">
|
||
<div className="flex items-center gap-3">
|
||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${RUN_BADGE[run.status]}`}>
|
||
{run.status}
|
||
</span>
|
||
<span className="text-sm text-slate-800">{run.automationLabel}</span>
|
||
<span className="text-xs font-mono text-slate-400">
|
||
step {Math.min(run.stepIndex + (run.status === 'done' ? 0 : 1), run.totalSteps)}/{run.totalSteps}
|
||
</span>
|
||
<span className="ml-auto text-xs text-slate-400">
|
||
{new Date(run.createdAt).toLocaleTimeString()}
|
||
</span>
|
||
{(run.status === 'queued' || run.status === 'running') && (
|
||
<button
|
||
onClick={() => cancelRun(run.id)}
|
||
className="text-xs text-slate-400 hover:text-red-500 transition-colors"
|
||
>
|
||
Stop
|
||
</button>
|
||
)}
|
||
</div>
|
||
{run.log.length > 0 && (
|
||
<ol className="mt-2 space-y-0.5">
|
||
{run.log.map((entry, i) => (
|
||
<li key={i} className="text-xs font-mono text-slate-500">
|
||
<span className={entry.ok ? 'text-green-600' : 'text-red-500'}>
|
||
{entry.ok ? '✓' : '✗'}
|
||
</span>{' '}
|
||
{entry.step}
|
||
{entry.detail && <span className="text-slate-400"> — {entry.detail}</span>}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
{run.error && (
|
||
<p className="mt-1 text-xs text-red-600">{run.error}</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Latest capture ── */}
|
||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||
Latest capture
|
||
</h2>
|
||
|
||
{!enabled ? (
|
||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-8 text-center text-slate-400">
|
||
Capture is off
|
||
</div>
|
||
) : !capture ? (
|
||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-8 text-center text-slate-400">
|
||
Waiting for the extension… open a page in Chrome with the extension installed
|
||
</div>
|
||
) : (
|
||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 px-4 py-3 border-b border-slate-100">
|
||
<span className="text-sm font-medium text-slate-800">{capture.title || '(untitled)'}</span>
|
||
<span className="text-xs font-mono text-slate-400 break-all">{capture.url}</span>
|
||
<span className="ml-auto flex items-center gap-3 text-xs text-slate-400">
|
||
<span>{(capture.html.length / 1024).toFixed(1)} KB</span>
|
||
<span className={stale ? 'text-amber-600' : ''}>
|
||
{new Date(capture.capturedAt).toLocaleTimeString()}
|
||
{stale && ' (stale)'}
|
||
</span>
|
||
<span className="flex rounded-md border border-slate-200 overflow-hidden">
|
||
{(['rendered', 'source'] as const).map((v) => (
|
||
<button
|
||
key={v}
|
||
onClick={() => (v === 'rendered' ? showRendered() : setView('source'))}
|
||
className={`px-2 py-0.5 capitalize transition-colors ${
|
||
view === v
|
||
? 'bg-slate-700 text-white'
|
||
: 'text-slate-500 hover:bg-slate-50'
|
||
}`}
|
||
>
|
||
{v}
|
||
</button>
|
||
))}
|
||
</span>
|
||
<button
|
||
onClick={clearCapture}
|
||
className="text-slate-400 hover:text-red-500 transition-colors"
|
||
>
|
||
Clear
|
||
</button>
|
||
</span>
|
||
</div>
|
||
|
||
{capture.viewport?.innerWidth != null && (
|
||
<div className="px-4 py-2 border-b border-slate-100 flex flex-wrap gap-x-5 gap-y-1 text-xs font-mono text-slate-500">
|
||
<span>viewport {capture.viewport.innerWidth}×{capture.viewport.innerHeight}</span>
|
||
<span>scroll {Math.round(capture.viewport.scrollX ?? 0)},{Math.round(capture.viewport.scrollY ?? 0)}</span>
|
||
<span>window@screen {capture.viewport.screenX},{capture.viewport.screenY}</span>
|
||
<span>chrome {capture.viewport.chromeHeight}px</span>
|
||
<span>dpr {capture.viewport.devicePixelRatio}</span>
|
||
</div>
|
||
)}
|
||
|
||
{capture.elements?.length > 0 && (
|
||
<div className="px-4 py-3 border-b border-slate-100">
|
||
<p className="text-xs uppercase tracking-wider text-slate-400 mb-2">
|
||
Matched elements ({capture.elements.length})
|
||
</p>
|
||
<div className="overflow-x-auto">
|
||
<table className="text-xs font-mono text-slate-600">
|
||
<thead className="text-slate-400">
|
||
<tr>
|
||
<th className="text-left pr-4 font-normal">tag</th>
|
||
<th className="text-left pr-4 font-normal">text</th>
|
||
<th className="text-right pr-4 font-normal">window x,y</th>
|
||
<th className="text-right pr-4 font-normal">size</th>
|
||
<th className="text-right pr-4 font-normal">page x,y</th>
|
||
<th className="text-right pr-4 font-normal">screen x,y</th>
|
||
<th className="text-left font-normal">vis</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{capture.elements.map((el) => (
|
||
<tr key={el.index} className="border-t border-slate-50">
|
||
<td className="pr-4 py-1">{el.tag}{el.id ? `#${el.id}` : ''}</td>
|
||
<td className="pr-4 py-1 max-w-xs truncate">{el.text}</td>
|
||
<td className="pr-4 py-1 text-right">{Math.round(el.viewport.x)},{Math.round(el.viewport.y)}</td>
|
||
<td className="pr-4 py-1 text-right">{Math.round(el.viewport.width)}×{Math.round(el.viewport.height)}</td>
|
||
<td className="pr-4 py-1 text-right">{Math.round(el.page.x)},{Math.round(el.page.y)}</td>
|
||
<td className="pr-4 py-1 text-right">{Math.round(el.screen.x)},{Math.round(el.screen.y)}</td>
|
||
<td className="py-1">{el.inViewport ? '✓' : el.visible ? 'off-screen' : 'hidden'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{view === 'rendered' ? (
|
||
<>
|
||
{pinned && pinned.capturedAt !== capture.capturedAt && (
|
||
<div className="px-4 py-1.5 border-b border-slate-100 text-xs text-amber-600">
|
||
Frozen snapshot from {new Date(pinned.capturedAt).toLocaleTimeString()} — click Rendered again to refresh
|
||
</div>
|
||
)}
|
||
{/* allow-scripts WITHOUT allow-same-origin: the frame runs the
|
||
page's own JS inside an opaque origin, so it cannot reach this
|
||
dashboard's DOM, storage, or same-origin API routes — /api/firms
|
||
serves firm credentials. Granting both flags together is what
|
||
would let a frame drop its own sandbox.
|
||
Scripts are needed because most sites ship content at opacity:0
|
||
and fade it in with JS — blocked, they render blank. */}
|
||
<iframe
|
||
sandbox="allow-scripts"
|
||
srcDoc={withBaseTag((pinned ?? capture).html, (pinned ?? capture).url)}
|
||
title="Captured page"
|
||
className="block w-full h-[70vh] bg-white"
|
||
/>
|
||
</>
|
||
) : (
|
||
<pre className="max-h-[70vh] overflow-auto bg-slate-900 text-slate-200 text-xs font-mono p-4 whitespace-pre-wrap break-all">
|
||
{capture.html}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|