Pinscope open-source core

Agentic schematic validation: datasheet extraction via Claude Console
Skills, netlist/BOM design graph, per-IC direct datasheet review with
page citations, capacitor derating, Next.js report UI.

Extracted from the Pinscope cloud codebase. Auth and billing live in the
private gateway repo behind stable seams (billing_hook.py, adapter files
listed in CLAUDE.md).
This commit is contained in:
Siddharth Kothari
2026-07-16 21:29:45 -07:00
commit 6672d2be57
254 changed files with 56662 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { readFile } from "node:fs/promises";
import path from "node:path";
const PROJECT_ROOT = path.resolve(process.cwd(), "..");
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const filePath = path.join(PROJECT_ROOT, id, "design_graph.json");
try {
const data = await readFile(filePath, "utf-8");
return NextResponse.json(JSON.parse(data));
} catch {
return NextResponse.json({ error: "Graph not found" }, { status: 404 });
}
}
@@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
import { PROJECTS } from "@/lib/mock-data";
export async function GET() {
return NextResponse.json(PROJECTS);
}
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { readFile } from "node:fs/promises";
import path from "node:path";
const PROJECT_ROOT = path.resolve(process.cwd(), "..");
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const filePath = path.join(PROJECT_ROOT, id, "report.json");
try {
const data = await readFile(filePath, "utf-8");
return NextResponse.json(JSON.parse(data));
} catch {
return NextResponse.json({ error: "Report not found" }, { status: 404 });
}
}
+384
View File
@@ -0,0 +1,384 @@
"use client";
import { Suspense, useState, useEffect, useMemo } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowRight, CheckCircle2, LayoutGrid, List, Loader2, X } from "lucide-react";
import { ProjectCard } from "@/components/dashboard/project-card";
import { ProjectsTable } from "@/components/dashboard/projects-table";
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
fetchProject,
fetchProjects,
reconcileCheckoutSession,
} from "@/lib/api";
import type { CreditSnapshot, Project } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useCredits } from "@/components/billing/credits-context";
import { OnboardingSurvey } from "@/components/dashboard/onboarding-survey";
type ViewMode = "cards" | "table";
const VIEW_STORAGE_KEY = "pinscopex:dashboard:view";
export default function DashboardPage() {
return (
<Suspense>
<DashboardContent />
</Suspense>
);
}
type CheckoutState = "pending" | "activated" | "timeout" | "dismissed";
function DashboardContent() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [rerunProject, setRerunProject] = useState<Project | null>(null);
const [cloneAsNewProject, setCloneAsNewProject] = useState<Project | null>(null);
const { credits, refresh: refreshCredits } = useCredits();
const [showCheckoutBanner, setShowCheckoutBanner] = useState(false);
const [checkoutState, setCheckoutState] =
useState<CheckoutState>("pending");
const [activatedDetail, setActivatedDetail] = useState<string | null>(null);
const [view, setView] = useState<ViewMode>("cards");
const router = useRouter();
const searchParams = useSearchParams();
// Restore persisted UI preferences once on mount.
useEffect(() => {
try {
const v = localStorage.getItem(VIEW_STORAGE_KEY);
if (v === "cards" || v === "table") setView(v);
} catch {
// ignore
}
}, []);
function updateView(next: ViewMode) {
setView(next);
try { localStorage.setItem(VIEW_STORAGE_KEY, next); } catch { /* ignore */ }
}
const sortedProjects = useMemo(
() =>
[...projects].sort((a, b) => {
const ta = new Date(a.created).getTime();
const tb = new Date(b.created).getTime();
return (Number.isFinite(tb) ? tb : 0) - (Number.isFinite(ta) ? ta : 0);
}),
[projects],
);
useEffect(() => {
fetchProjects()
.then(setProjects)
.finally(() => setLoading(false));
// Admin handoff: "Rerun as new project" stashes a project ID here.
const cloneId =
typeof window !== "undefined"
? window.sessionStorage.getItem("pinscopex:cloneAsNewProjectId")
: null;
if (cloneId) {
window.sessionStorage.removeItem("pinscopex:cloneAsNewProjectId");
fetchProject(cloneId)
.then(setCloneAsNewProject)
.catch(() => {
// Source project unreachable — silently no-op; dialog stays closed.
});
}
const topupSuccess = searchParams.get("topup") === "success";
if (!topupSuccess) return;
setShowCheckoutBanner(true);
setCheckoutState("pending");
const sessionId = searchParams.get("session_id");
let cancelled = false;
let poll: ReturnType<typeof setInterval> | null = null;
const activate = (balance: number) => {
if (cancelled) return;
setActivatedDetail(`Balance is now ${balance.toFixed(2)} credits.`);
setCheckoutState("activated");
};
(async () => {
const initial = await refreshCredits();
const initialBalance: number | null = initial?.balance ?? null;
// Reconcile is idempotent — a confirmed paid top-up session means
// the grant is already in the ledger, so we skip the balance-delta
// poll (which would hang after a refresh since the credits are
// already applied).
if (sessionId) {
try {
const r = await reconcileCheckoutSession(sessionId);
if (!cancelled && r.ok && r.kind === "topup" && r.payment_status === "paid") {
const c = await refreshCredits();
activate(c?.balance ?? initialBalance ?? 0);
return;
}
} catch {
/* fall through to polling */
}
}
let attempts = 0;
const MAX_ATTEMPTS = 8; // ~16s — after that we drop to a Refresh CTA
poll = setInterval(async () => {
if (cancelled) return;
attempts++;
const c = await refreshCredits();
const balanceIncreased =
c != null &&
initialBalance != null &&
c.balance > initialBalance + 0.001;
if (balanceIncreased && c) {
activate(c.balance);
if (poll) clearInterval(poll);
} else if (attempts >= MAX_ATTEMPTS) {
setCheckoutState("timeout");
if (poll) clearInterval(poll);
}
}, 2000);
})();
return () => {
cancelled = true;
if (poll) clearInterval(poll);
};
}, [searchParams, refreshCredits]);
useEffect(() => {
if (checkoutState !== "activated") return;
const url = new URL(window.location.href);
if (url.searchParams.has("topup") || url.searchParams.has("session_id")) {
url.searchParams.delete("topup");
url.searchParams.delete("session_id");
router.replace(url.pathname + (url.search || ""));
}
const t = setTimeout(() => {
setCheckoutState("dismissed");
setShowCheckoutBanner(false);
}, 4000);
return () => clearTimeout(t);
}, [checkoutState, router]);
function dismissCheckout() {
setCheckoutState("dismissed");
setShowCheckoutBanner(false);
const url = new URL(window.location.href);
url.searchParams.delete("topup");
url.searchParams.delete("session_id");
router.replace(url.pathname + (url.search || ""));
}
return (
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
<OnboardingSurvey />
{showCheckoutBanner && checkoutState !== "dismissed" && (
<CheckoutSuccessBanner
state={checkoutState}
detail={activatedDetail}
onDismiss={dismissCheckout}
/>
)}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-lg font-semibold">Projects</h1>
<BalanceSubtitle credits={credits} />
</div>
<CreateProjectDialog
rerunProject={rerunProject}
onRerunDone={() => setRerunProject(null)}
cloneAsNewProject={cloneAsNewProject}
onCloneAsNewDone={() => setCloneAsNewProject(null)}
onCreateProject={(p) => {
setProjects((prev) => {
const existing = prev.find((x) => x.id === p.id);
if (existing) {
return prev.map((x) => (x.id === p.id ? p : x));
}
return [p, ...prev];
});
router.push(`/project/${p.id}/progress`);
}}
/>
</div>
{!loading && projects.length > 0 && (
<div className="flex items-center justify-end gap-2 mb-4">
<div className="inline-flex rounded-lg border border-input p-0.5">
<button
type="button"
onClick={() => updateView("cards")}
aria-pressed={view === "cards"}
title="Card view"
className={cn(
"p-1 rounded-md transition-colors",
view === "cards"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<LayoutGrid className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => updateView("table")}
aria-pressed={view === "table"}
title="Table view"
className={cn(
"p-1 rounded-md transition-colors",
view === "table"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<List className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-lg" />
))}
</div>
) : projects.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
No projects yet. Create one to get started.
</p>
) : view === "table" ? (
<ProjectsTable
projects={sortedProjects}
onDeleted={(id) => setProjects((prev) => prev.filter((x) => x.id !== id))}
onRerun={setRerunProject}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedProjects.map((p) => (
<ProjectCard
key={p.id}
project={p}
onDeleted={() => setProjects((prev) => prev.filter((x) => x.id !== p.id))}
onRerun={setRerunProject}
/>
))}
</div>
)}
</div>
);
}
function BalanceSubtitle({ credits }: { credits: CreditSnapshot | null }) {
if (!credits) {
return (
<p className="text-sm text-muted-foreground">
Schematic validation projects
</p>
);
}
return (
<p className="text-sm text-muted-foreground">
{credits.balance.toFixed(2)} credits
{" · "}
<Link
href="/billing"
className="underline underline-offset-2 hover:text-foreground"
>
buy more
</Link>
{" · "}
<Link
href="/credits"
className="underline underline-offset-2 hover:text-foreground"
>
ledger
</Link>
</p>
);
}
function CheckoutSuccessBanner({
state,
detail,
onDismiss,
}: {
state: CheckoutState;
detail: string | null;
onDismiss: () => void;
}) {
const tone =
state === "timeout"
? "border-amber-500/30 bg-amber-500/5 text-amber-600 dark:text-amber-400"
: "border-emerald-500/30 bg-emerald-500/5 text-emerald-600 dark:text-emerald-400";
let icon;
let heading;
let body;
let action;
if (state === "pending") {
icon = <Loader2 className="h-4 w-4 shrink-0 animate-spin" />;
heading = "Payment received";
body = "Applying your top-up to your balance…";
} else if (state === "activated") {
icon = <CheckCircle2 className="h-4 w-4 shrink-0" />;
heading = "Top-up complete";
body = detail;
action = (
<Link href="/credits">
<Button size="sm" variant="outline" className="h-7 text-xs">
View ledger
<ArrowRight className="h-3 w-3 ml-1" />
</Button>
</Link>
);
} else {
// timeout
icon = <Loader2 className="h-4 w-4 shrink-0" />;
heading = "Still processing";
body =
"Stripe is taking longer than usual. Refresh the page in a moment — your credits will appear automatically once the webhook completes.";
action = (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => window.location.reload()}
>
Refresh
</Button>
);
}
return (
<div className={`rounded-lg border px-4 py-3 text-sm mb-4 flex items-start gap-3 ${tone}`}>
{icon}
<div className="flex-1 min-w-0">
<div className="font-medium">{heading}</div>
{body && (
<div className="text-xs text-muted-foreground mt-0.5">{body}</div>
)}
</div>
{action}
<button
type="button"
onClick={onDismiss}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useEffect, useState } from "react";
import { MessageSquareWarning } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { fetchMyFeedback, type FeedbackTicket } from "@/lib/api";
const STATUS_STYLES: Record<string, string> = {
open: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30",
acknowledged: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30",
resolved: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30",
};
function formatRelativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export default function FeedbackPage() {
const [tickets, setTickets] = useState<FeedbackTicket[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<string | null>(null);
useEffect(() => {
fetchMyFeedback()
.then(setTickets)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="flex-1 p-6 max-w-4xl mx-auto w-full space-y-4">
<Skeleton className="h-8 w-48" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 rounded-lg" />
))}
</div>
);
}
return (
<div className="flex-1 p-6 max-w-4xl mx-auto w-full space-y-6">
<div>
<h1 className="text-lg font-semibold">My Feedback</h1>
<p className="text-sm text-muted-foreground">
{tickets.length} ticket{tickets.length !== 1 ? "s" : ""} submitted
</p>
</div>
{tickets.length === 0 ? (
<div className="rounded-lg border border-border bg-card p-12 text-center space-y-3">
<MessageSquareWarning className="h-8 w-8 text-muted-foreground mx-auto" />
<p className="text-sm text-muted-foreground">
No feedback submitted yet. Use the Feedback button in the sidebar or the flag icon on a finding to submit.
</p>
</div>
) : (
<div className="space-y-2">
{tickets.map((t) => (
<button
key={t.ticket_id}
type="button"
onClick={() => setExpanded(expanded === t.ticket_id ? null : t.ticket_id)}
className="w-full text-left rounded-lg border border-border bg-card p-4 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Badge variant="outline" className={`text-xs shrink-0 ${STATUS_STYLES[t.status] ?? ""}`}>
{t.status}
</Badge>
{t.finding_id && (
<span className="font-mono text-xs text-muted-foreground shrink-0">
{t.finding_id}
</span>
)}
{t.project_name && (
<span className="text-xs text-muted-foreground truncate">
{t.project_name}
</span>
)}
<span className="ml-auto text-xs text-muted-foreground shrink-0">
{formatRelativeTime(t.created_at)}
</span>
</div>
<p className={`text-sm text-muted-foreground mt-2 ${expanded === t.ticket_id ? "" : "line-clamp-2"}`}>
{t.message}
</p>
{expanded === t.ticket_id && t.finding_text && (
<div className="mt-3 rounded border border-border/60 bg-muted/30 p-3 space-y-1">
<p className="text-xs text-muted-foreground">Reported finding:</p>
<p className="text-sm">{t.finding_text}</p>
<div className="flex gap-2 text-xs text-muted-foreground">
{t.finding_designator && <span className="font-mono">{t.finding_designator}</span>}
{t.finding_mpn && <span className="font-mono">{t.finding_mpn}</span>}
</div>
</div>
)}
{expanded === t.ticket_id && t.admin_notes && (
<div className="mt-3 rounded border border-emerald-500/20 bg-emerald-500/5 p-3">
<p className="text-xs text-emerald-600 dark:text-emerald-400 mb-1">Pinscope Team:</p>
<p className="text-sm text-muted-foreground">{t.admin_notes}</p>
</div>
)}
</button>
))}
</div>
)}
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { TooltipProvider } from "@/components/ui/tooltip";
import { Sidebar } from "@/components/layout/sidebar";
import { CreditsProvider } from "@/components/billing/credits-context";
import { RedditPixelMatchKeys } from "@/components/analytics/reddit-pixel-match-keys";
export default function AppLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<TooltipProvider>
<CreditsProvider>
<div className="flex h-full">
<Sidebar />
<main className="flex-1 flex flex-col overflow-auto">
{children}
</main>
</div>
<RedditPixelMatchKeys />
</CreditsProvider>
</TooltipProvider>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,361 @@
"use client";
import { use, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { PipelineStepper } from "@/components/progress/pipeline-stepper";
import { PausedRunBanner } from "@/components/billing/paused-run-banner";
import { usePipelineProgress } from "@/hooks/use-pipeline-progress";
import { cancelPipeline, fetchProject, resumePipeline } from "@/lib/api";
import type { PauseCheckpoint } from "@/lib/types";
import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Coffee,
Coins,
Loader2,
OctagonX,
Ban,
} from "lucide-react";
export default function ProgressPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const router = useRouter();
const { steps, done, cancelled, error, summary, autoTopupFailure, credits, started, paused } =
usePipelineProgress(id);
const [topupDismissed, setTopupDismissed] = useState(false);
const [projectName, setProjectName] = useState<string>("");
const [confirmInput, setConfirmInput] = useState("");
const [cancelling, setCancelling] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [projectPaused, setProjectPaused] = useState(false);
const [projectCheckpoint, setProjectCheckpoint] = useState<PauseCheckpoint | null>(null);
const [resuming, setResuming] = useState(false);
// Fetch project name + initial paused state. The SSE stream only reports
// `pipeline_paused` if the page is open when it fires; landing on the
// progress page later, we need to read the persisted project status.
useEffect(() => {
fetchProject(id)
.then((p) => {
setProjectName(p.name);
if (p.status === "paused_insufficient_credits") {
setProjectPaused(true);
setProjectCheckpoint(p.pauseCheckpoint ?? null);
}
})
.catch(() => {});
}, [id]);
// Live `pipeline_paused` event also flips the paused state.
useEffect(() => {
if (paused) {
setProjectPaused(true);
setProjectCheckpoint({
paused_at: paused.unit_id,
paused_stage: paused.stage,
last_completed_label: paused.last_completed,
completed_review_refs: paused.completed_review_refs ?? [],
pending_review_refs: paused.pending_review_refs ?? [],
});
}
}, [paused]);
const handleResume = async () => {
setResuming(true);
try {
await resumePipeline(id);
// Refresh the page so a new SSE connection picks up the resumed run
// from a clean slate.
window.location.reload();
} catch (e) {
setResuming(false);
alert(e instanceof Error ? e.message : "Failed to resume pipeline");
}
};
// Auto-navigate to report when pipeline completes successfully
useEffect(() => {
if (done && !error && !cancelled && !projectPaused) {
const timer = setTimeout(() => {
router.push(`/project/${id}/report`);
}, 1500);
return () => clearTimeout(timer);
}
}, [done, error, cancelled, projectPaused, id, router]);
// Auto-navigate to dashboard when pipeline is cancelled
useEffect(() => {
if (cancelled) {
const timer = setTimeout(() => {
router.push("/dashboard");
}, 1500);
return () => clearTimeout(timer);
}
}, [cancelled, router]);
const handleCancel = async () => {
setCancelling(true);
try {
await cancelPipeline(id);
} catch {
// Pipeline may have already finished
} finally {
setCancelling(false);
setDialogOpen(false);
setConfirmInput("");
}
};
const isRunning = !done && !projectPaused;
const isQueued = isRunning && !started;
return (
<div className="flex-1 p-6 max-w-3xl mx-auto w-full space-y-6">
<div>
<h1 className="text-lg font-semibold">Pipeline Progress</h1>
<p className="text-sm text-muted-foreground">
{cancelled
? "Pipeline cancelled"
: projectPaused
? "Pipeline paused — out of credits"
: done
? "Validation complete"
: isQueued
? "Project queued, starting pipeline..."
: "Running validation pipeline..."}
</p>
</div>
{projectPaused && (
<PausedRunBanner
projectId={id}
checkpoint={projectCheckpoint}
resuming={resuming}
onResume={handleResume}
/>
)}
{isQueued && (
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/10 via-blue-500/[0.04] to-transparent overflow-hidden">
<div className="relative shrink-0">
<span
aria-hidden
className="absolute inset-0 -m-2 rounded-full bg-blue-400/30 blur-xl animate-pulse"
/>
<Loader2
className="relative h-8 w-8 text-blue-700 dark:text-blue-300 animate-spin drop-shadow-[0_0_10px_rgba(96,165,250,0.75)]"
strokeWidth={2.25}
/>
</div>
<div className="flex-1">
<p className="text-base font-medium text-foreground">
Project queued, starting pipeline
</p>
<p className="text-sm text-muted-foreground mt-0.5">
Waiting for a worker to pick this run up. Usually takes a few seconds.
</p>
</div>
</div>
)}
{isRunning && !isQueued && (
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-amber-500/30 bg-gradient-to-r from-amber-500/10 via-amber-500/[0.04] to-transparent overflow-hidden">
<div className="relative shrink-0">
<span
aria-hidden
className="absolute inset-0 -m-2 rounded-full bg-amber-400/30 blur-xl animate-pulse"
/>
<Coffee
className="relative h-8 w-8 text-amber-700 dark:text-amber-300 drop-shadow-[0_0_10px_rgba(251,191,36,0.75)]"
strokeWidth={2.25}
/>
</div>
<div className="flex-1">
<p className="text-base font-medium text-foreground">
Feel free to grab a coffee
</p>
<p className="text-sm text-muted-foreground mt-0.5">
You can close this tab we&apos;ll email you when it&apos;s ready.
</p>
</div>
</div>
)}
{autoTopupFailure && !topupDismissed && (
<div className="flex items-start gap-3 p-4 rounded-lg border border-amber-500/30 bg-amber-500/5">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
<div className="flex-1 text-sm">
<p className="font-medium text-amber-800 dark:text-amber-200">Auto top-up failed</p>
<p className="text-xs text-muted-foreground mt-0.5">
{autoTopupFailure.amount_usd
? `The $${autoTopupFailure.amount_usd.toFixed(0)} charge`
: "Your scheduled top-up"}{" "}
was declined ({autoTopupFailure.reason}). Auto top-up has been
disabled top up manually to avoid pausing.
</p>
</div>
<div className="flex items-center gap-2">
<Link href="/credits">
<Button variant="outline" size="sm">
Top up
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setTopupDismissed(true)}
>
Dismiss
</Button>
</div>
</div>
)}
{(credits || isRunning) && (
<div className="flex items-center justify-between gap-4 px-3 py-1.5 rounded-md border border-border/40 bg-muted/20 text-xs text-muted-foreground">
<div className="flex items-center gap-2 min-w-0">
<Coins
key={credits?.ts ?? 0}
className="h-3.5 w-3.5 text-amber-600/60 dark:text-amber-400/60 shrink-0 data-[bump=true]:animate-pulse"
data-bump={credits ? "true" : "false"}
/>
<span>Spent this run</span>
<span className="font-mono tabular-nums text-foreground/80">
{credits ? credits.credits_spent.toFixed(2) : "0.00"}
</span>
{credits?.stage && (
<span className="hidden sm:inline text-muted-foreground/70 truncate">
· {credits.stage}
{credits.unit_id ? ` · ${credits.unit_id}` : ""}
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span>Balance</span>
<span className="font-mono tabular-nums text-foreground/80">
{credits ? credits.balance_after.toFixed(2) : "—"}
</span>
</div>
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-sm">Steps</CardTitle>
</CardHeader>
<CardContent>
<PipelineStepper steps={steps} />
</CardContent>
</Card>
{isRunning && (
<AlertDialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open);
if (!open) setConfirmInput("");
}}>
<AlertDialogTrigger
render={
<Button variant="outline" size="sm" className="text-rose-600 dark:text-rose-400 border-rose-500/30 hover:bg-rose-500/10">
<OctagonX className="h-4 w-4 mr-1.5" />
Cancel Project
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel pipeline?</AlertDialogTitle>
<AlertDialogDescription>
This will stop the validation pipeline. The project will still
count toward your project limit.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2 py-2">
<p className="text-sm text-muted-foreground">
Type <span className="font-semibold font-mono text-foreground">{projectName}</span> to
confirm:
</p>
<Input
value={confirmInput}
onChange={(e) => setConfirmInput(e.target.value)}
placeholder={projectName}
autoFocus
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Go back</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={confirmInput !== projectName || cancelling}
onClick={(e) => {
e.preventDefault();
handleCancel();
}}
>
{cancelling ? "Cancelling..." : "Cancel project"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{done && !error && !cancelled && (
<div className="flex items-center gap-3 p-4 rounded-lg border border-emerald-500/30 bg-emerald-500/5">
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400 shrink-0" />
<span className="text-sm flex-1">
All checks complete.{" "}
{summary &&
`${summary.total} findings: ${summary.PASS} pass, ${summary.WARNING} warnings, ${summary.ERROR} errors.`}
</span>
<Link href={`/project/${id}/report`}>
<Button size="sm">
View Report
<ArrowRight className="h-4 w-4 ml-1" />
</Button>
</Link>
</div>
)}
{done && cancelled && (
<div className="flex items-center gap-3 p-4 rounded-lg border border-amber-500/30 bg-amber-500/5">
<Ban className="h-5 w-5 text-amber-600 dark:text-amber-400 shrink-0" />
<span className="text-sm text-amber-600 dark:text-amber-400 flex-1">
Pipeline was cancelled. This project still counts toward your project
limit.
</span>
<Link href="/dashboard">
<Button variant="outline" size="sm">
Back to Dashboard
</Button>
</Link>
</div>
)}
{done && error && !cancelled && (
<div className="flex items-center gap-3 p-4 rounded-lg border border-rose-500/30 bg-rose-500/5">
<span className="text-sm text-rose-600 dark:text-rose-400">{error}</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,354 @@
"use client";
import { use, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef, Suspense } from "react";
import { Download } from "lucide-react";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { useReport } from "@/hooks/use-report";
import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
import { ReportSummary } from "@/components/report/report-summary";
import { FindingsList } from "@/components/report/findings-list";
import { FindingFocusView } from "@/components/report/finding-focus-view";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Toast, useToast } from "@/components/ui/toast";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { fetchCollaborators, fetchProject, fetchMyFeedback } from "@/lib/api";
import { exportReportToExcel } from "@/lib/report-export";
import { cn, getFindingKey } from "@/lib/utils";
import type { Finding, FindingComment, Collaborator } from "@/lib/types";
interface FocusState {
key: string;
finding: Finding;
mpn: string;
page: number;
quote?: string;
}
function ReportContent({ projectId }: { projectId: string }) {
const { report, graph, loading, error } = useReport(projectId);
const { user } = useOptionalUser();
const [focus, setFocus] = useState<FocusState | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const savedScrollRef = useRef(0);
const prevInFocusRef = useRef(false);
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
const [comments, setComments] = useState<Record<string, FindingComment[]>>({});
const [creditsSpent, setCreditsSpent] = useState<number | undefined>();
const [projectName, setProjectName] = useState<string>("");
const [feedbackFinding, setFeedbackFinding] = useState<Finding | null>(null);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const [reportedFindingIds, setReportedFindingIds] = useState<Set<string>>(new Set());
useEffect(() => {
fetchMyFeedback()
.then((tickets) => {
const ids = new Set<string>();
for (const t of tickets) {
if (t.finding_id && t.project_id === projectId) ids.add(t.finding_id);
}
setReportedFindingIds(ids);
})
.catch(() => {});
}, [projectId]);
useEffect(() => {
fetchProject(projectId)
.then((p) => {
setCreditsSpent(p.creditsSpent);
setProjectName(p.name);
})
.catch(() => {});
}, [projectId]);
const { reviewedCount, isReviewed, toggleReviewed } = useReviewedFindings(
projectId,
report?.findings ?? []
);
const { toast, show: showToast } = useToast();
const findingByKey = useMemo(() => {
const map = new Map<string, Finding>();
(report?.findings ?? []).forEach((f, i) => map.set(getFindingKey(f, i), f));
return map;
}, [report?.findings]);
const keyByFinding = useMemo(() => {
const map = new Map<Finding, string>();
(report?.findings ?? []).forEach((f, i) => map.set(f, getFindingKey(f, i)));
return map;
}, [report?.findings]);
const handleToggleReviewed = useCallback(
(key: string) => {
const wasReviewed = isReviewed(key);
toggleReviewed(key);
if (!wasReviewed) {
const finding = findingByKey.get(key);
const label = finding?.finding_id ?? finding?.designator ?? "Rule";
showToast(`${label} marked as reviewed`);
}
},
[isReviewed, toggleReviewed, findingByKey, showToast]
);
// Load collaborators
useEffect(() => {
fetchCollaborators(projectId)
.then((data) => setCollaborators(data.collaborators))
.catch(() => {});
}, [projectId]);
// Sync comments from report
useEffect(() => {
if (report?.comments) {
setComments(report.comments);
}
}, [report]);
const handleCommentAdded = useCallback((comment: FindingComment) => {
setComments((prev) => ({
...prev,
[comment.finding_id]: [...(prev[comment.finding_id] ?? []), comment],
}));
}, []);
const handleCommentDeleted = useCallback((commentId: string, findingId: string) => {
setComments((prev) => {
const list = (prev[findingId] ?? []).filter((c) => c.comment_id !== commentId);
const next = { ...prev };
if (list.length === 0) {
delete next[findingId];
} else {
next[findingId] = list;
}
return next;
});
}, []);
const handleReportFinding = useCallback((finding: Finding) => {
setFeedbackFinding(finding);
setFeedbackOpen(true);
}, []);
const handleViewReference = useCallback(
(finding: Finding) => {
if (!graph) return;
// source_page/source_quote may cite a *connected* component's datasheet
// (evidence pulled from a neighbor's excerpt during review). Open that
// datasheet, not the component under review — else the page is in the
// wrong PDF (and often past its end, so nothing renders).
const sourceDesignator = finding.source_designator ?? finding.designator;
const mpn =
graph.components[sourceDesignator]?.mpn ??
graph.components[finding.designator]?.mpn;
if (!mpn) {
showToast(`No datasheet on file for ${sourceDesignator}`);
return;
}
// Save the list scroll position only when entering focus mode, not when
// swapping between findings while already focused.
if (!focus) {
savedScrollRef.current = rootRef.current?.closest("main")?.scrollTop ?? 0;
}
setFocus({
key: keyByFinding.get(finding) ?? finding.finding_id ?? finding.designator,
finding,
mpn,
page: finding.source_page ?? 1,
quote: finding.source_quote,
});
},
[graph, focus, keyByFinding, showToast]
);
const inFocus = focus !== null;
useLayoutEffect(() => {
const wasInFocus = prevInFocusRef.current;
prevInFocusRef.current = inFocus;
if (wasInFocus === inFocus) return;
const scroller = rootRef.current?.closest("main");
if (!scroller) return;
scroller.scrollTop = inFocus ? 0 : savedScrollRef.current;
}, [inFocus]);
if (loading) {
return (
<div className="p-6 max-w-5xl mx-auto w-full space-y-4">
<div className="grid grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-20 rounded-lg" />
))}
</div>
<Skeleton className="h-8 rounded" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 rounded-lg" />
))}
</div>
);
}
if (error || !report || !graph) {
return (
<div className="p-6 max-w-5xl mx-auto w-full text-center py-12 text-sm text-muted-foreground">
{error ?? "Report not found."}
</div>
);
}
return (
<div
ref={rootRef}
className={cn("p-6 mx-auto w-full", focus ? "max-w-[1920px]" : "max-w-5xl")}
>
<div className={cn("space-y-6", focus && "hidden")}>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-lg font-semibold">Validation Report</h1>
<p className="text-sm text-muted-foreground">
{report.findings.length} findings &middot;{" "}
{new Date(report.timestamp).toLocaleDateString()}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => exportReportToExcel(report, graph, projectName)}
disabled={report.findings.length === 0}
>
<Download /> Export Excel
</Button>
</div>
<ReportSummary
summary={report.summary}
reviewedCount={reviewedCount}
creditsSpent={creditsSpent}
/>
{report.review_errors && Object.keys(report.review_errors).length > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 space-y-2">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
{Object.keys(report.review_errors).length} IC review{Object.keys(report.review_errors).length === 1 ? "" : "s"} failed
</p>
<p className="text-xs text-muted-foreground">
These ICs could not be reviewed against their datasheets. Re-run the pipeline to retry; if the failure repeats, share the error with support.
</p>
<ul className="space-y-1 text-xs">
{Object.entries(report.review_errors).map(([ref, err]) => (
<li key={ref} className="flex gap-2">
<span className="font-mono font-medium text-amber-700 dark:text-amber-300 shrink-0">{ref}</span>
<span className="text-muted-foreground break-all">{err}</span>
</li>
))}
</ul>
</div>
)}
{report.not_reviewed && report.not_reviewed.length > 0 && (
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-2">
<p className="text-sm font-medium">
{report.not_reviewed.length} component{report.not_reviewed.length === 1 ? "" : "s"} not reviewed
</p>
<p className="text-xs text-muted-foreground">
These components have no datasheet on file, so they were not checked against one. A reversed or mis-wired pin on an unreviewed part (e.g. a DNP footprint with no BOM entry) cannot be caught here verify these manually.
</p>
<ul className="space-y-1 text-xs">
{report.not_reviewed.map((nr) => (
<li key={nr.designator} className="flex gap-2">
<span className="font-mono font-medium shrink-0">{nr.designator}</span>
<span className="text-muted-foreground break-all">{nr.reason}</span>
</li>
))}
</ul>
</div>
)}
{report.summary.total === 0 ? (
<div className="rounded-lg border border-border bg-card p-8 text-center space-y-2">
<p className="text-sm font-medium">No findings</p>
<p className="text-xs text-muted-foreground">
No IC datasheets were available for review. Upload datasheets for each IC
on the project page and re-run the pipeline to get findings.
</p>
</div>
) : (
<FindingsList
findings={report.findings}
graph={graph}
onViewReference={handleViewReference}
projectId={projectId}
isReviewed={isReviewed}
toggleReviewed={handleToggleReviewed}
comments={comments}
collaborators={collaborators}
currentUserId={user?.id}
currentUserName={user?.name ?? user?.email ?? "User"}
onCommentAdded={handleCommentAdded}
onCommentDeleted={handleCommentDeleted}
onReportFinding={handleReportFinding}
reportedFindingIds={reportedFindingIds}
/>
)}
</div>
{focus && (
<FindingFocusView
finding={focus.finding}
component={graph.components[focus.finding.designator]}
mpn={focus.mpn}
page={focus.page}
quote={focus.quote}
projectId={projectId}
onExit={() => setFocus(null)}
escapeDisabled={feedbackOpen}
onViewReference={handleViewReference}
checked={isReviewed(focus.key)}
onCheckedChange={() => handleToggleReviewed(focus.key)}
comments={focus.finding.finding_id ? comments[focus.finding.finding_id] : undefined}
collaborators={collaborators}
currentUserId={user?.id}
currentUserName={user?.name ?? user?.email ?? "User"}
onCommentAdded={handleCommentAdded}
onCommentDeleted={handleCommentDeleted}
onReportFinding={handleReportFinding}
isReported={!!(focus.finding.finding_id && reportedFindingIds.has(focus.finding.finding_id))}
/>
)}
<Toast toast={toast} />
<FeedbackDialog
open={feedbackOpen}
onOpenChange={(open) => {
setFeedbackOpen(open);
if (!open) setFeedbackFinding(null);
}}
projectId={projectId}
projectName={projectName}
findingContext={
feedbackFinding?.finding_id
? {
finding_id: feedbackFinding.finding_id,
finding_text: feedbackFinding.finding,
designator: feedbackFinding.designator,
mpn: feedbackFinding.mpn,
status: feedbackFinding.status,
}
: undefined
}
onSubmitted={() => {
showToast("Feedback submitted");
if (feedbackFinding?.finding_id) {
setReportedFindingIds((prev) => new Set(prev).add(feedbackFinding.finding_id!));
}
}}
/>
</div>
);
}
export default function ReportPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
return (
<div className="flex-1 w-full">
<Suspense>
<ReportContent projectId={id} />
</Suspense>
</div>
);
}
@@ -0,0 +1,19 @@
import fs from "fs";
import path from "path";
import { ChangelogTimeline } from "@/components/legal/changelog-timeline";
import { pageMetadata } from "@/lib/site";
export const metadata = pageMetadata({
title: "Changelog",
description:
"Recent updates and improvements to Pinscope — new EDA tool support, review accuracy improvements, and platform changes.",
path: "/changelog",
});
export default function ChangelogPage() {
const content = fs.readFileSync(
path.join(process.cwd(), "content", "changelog.md"),
"utf-8",
);
return <ChangelogTimeline content={content} />;
}
@@ -0,0 +1,43 @@
const BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
export type ActionState = {
success: boolean;
message: string;
} | null;
export async function submitContactForm(data: {
name: string;
email: string;
company: string;
subject: string;
message: string;
_honey: string;
}): Promise<ActionState> {
try {
const resp = await fetch(`${BASE}/api/contact`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await resp.json();
if (!resp.ok) {
// Pydantic validation errors
if (result.detail && Array.isArray(result.detail)) {
return { success: false, message: "Please check your input and try again." };
}
return {
success: false,
message: result.message || "Something went wrong. Please try again or email us directly at sid@faradworks.com.",
};
}
return result as ActionState;
} catch {
return {
success: false,
message: "Could not reach the server. Please try again or email us directly at sid@faradworks.com.",
};
}
}
@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { Loader2, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { submitContactForm, type ActionState } from "./actions";
export function ContactForm() {
const [state, setState] = useState<ActionState>(null);
const [pending, setPending] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
const form = e.currentTarget;
const formData = new FormData(form);
const result = await submitContactForm({
name: (formData.get("name") as string) ?? "",
email: (formData.get("email") as string) ?? "",
company: (formData.get("company") as string) ?? "",
subject: (formData.get("subject") as string) ?? "",
message: (formData.get("message") as string) ?? "",
_honey: (formData.get("_honey") as string) ?? "",
});
setState(result);
setPending(false);
}
if (state?.success) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<CheckCircle2 className="h-12 w-12 text-emerald-500 mb-4" />
<h3 className="font-headline text-xl tracking-tight">Message sent</h3>
<p className="mt-2 text-sm text-muted-foreground max-w-sm">
{state.message}
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Honeypot */}
<input
name="_honey"
type="text"
className="sr-only"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
name="name"
required
maxLength={200}
placeholder="Your name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
required
maxLength={254}
placeholder="you@company.com"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company">
Company <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Input
id="company"
name="company"
maxLength={200}
placeholder="Company name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="subject">
Subject <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Input
id="subject"
name="subject"
maxLength={200}
placeholder="What's this about?"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="message">Message</Label>
<Textarea
id="message"
name="message"
required
maxLength={5000}
rows={6}
placeholder="How can we help?"
/>
</div>
{state?.success === false && (
<p className="text-sm text-destructive" role="alert">
{state.message}
</p>
)}
<Button
type="submit"
disabled={pending}
className="bg-blue-600 hover:bg-blue-500 text-white border-0 px-8 text-base h-12 w-full sm:w-auto"
>
{pending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Sending...
</>
) : (
"Send message"
)}
</Button>
</form>
);
}
@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { Cpu, ArrowRight } from "lucide-react";
import { useOptionalAuth } from "@/hooks/use-optional-auth";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme/theme-toggle";
export function Nav() {
const { isSignedIn } = useOptionalAuth();
return (
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-lg">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-6">
<Link href="/" className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-blue-500" />
<span className="text-sm font-semibold tracking-tight">Pinscope</span>
</Link>
<nav className="hidden sm:flex items-center gap-6 text-sm text-muted-foreground">
<Link href="/#features" className="hover:text-foreground transition-colors">
Features
</Link>
<Link href="/#security" className="hover:text-foreground transition-colors">
Security
</Link>
<Link href="/#pricing" className="hover:text-foreground transition-colors">
Pricing
</Link>
<Link href="/contact" className="text-foreground">
Contact
</Link>
</nav>
<div className="flex items-center gap-3">
<ThemeToggle />
{isSignedIn ? (
<Link href="/dashboard">
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-500 text-white border-0"
>
Dashboard
<ArrowRight className="h-3.5 w-3.5" />
</Button>
</Link>
) : (
<>
<Link href="/login">
<Button variant="ghost" size="sm">
Sign in
</Button>
</Link>
<Link href="/login">
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-500 text-white border-0"
>
Get started
</Button>
</Link>
</>
)}
</div>
</div>
</header>
);
}
@@ -0,0 +1,78 @@
import Link from "next/link";
import { Cpu, ArrowRight } from "lucide-react";
import { ContactForm } from "./contact-form";
import { Nav } from "./nav";
import { pageMetadata } from "@/lib/site";
export const metadata = pageMetadata({
title: "Contact",
description:
"Talk to the Pinscope team — questions, account help, or enterprise deployment.",
path: "/contact",
});
export default function ContactPage() {
return (
<div className="flex flex-col min-h-full">
<Nav />
{/* ── Hero ── */}
<section className="mx-auto max-w-3xl px-6 pt-24 sm:pt-36 pb-12">
<h1 className="font-headline text-3xl sm:text-4xl tracking-tight animate-fade-up">
Get in touch
</h1>
<p className="mt-4 text-muted-foreground max-w-lg leading-relaxed animate-fade-up [animation-delay:100ms]">
Have a question about Pinscope, need help with your account, or want to
discuss enterprise deployment? We&rsquo;d love to hear from you.
</p>
</section>
{/* ── Form ── */}
<section className="mx-auto max-w-3xl px-6 pb-24 w-full animate-fade-up [animation-delay:200ms]">
<div className="rounded-xl border border-border bg-card/40 p-6 sm:p-8">
<ContactForm />
</div>
</section>
{/* ── Alternative ── */}
<section className="border-t border-border/50 mt-auto">
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
<p className="text-sm text-muted-foreground">
Prefer email? Reach us directly at{" "}
<a
href="mailto:dev@faradworks.com"
className="text-foreground hover:underline"
>
dev@faradworks.com
</a>
</p>
</div>
</section>
{/* ── Footer ── */}
<footer className="border-t border-border/50">
<div className="mx-auto max-w-6xl px-6 py-8 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-blue-500" />
<span>Pinscope</span>
</div>
<div className="flex items-center gap-4">
<Link
href="/privacy"
className="hover:text-foreground transition-colors"
>
Privacy
</Link>
<Link
href="/terms"
className="hover:text-foreground transition-colors"
>
Terms
</Link>
<span>&copy; {new Date().getFullYear()} Faradworks</span>
</div>
</div>
</footer>
</div>
);
}
@@ -0,0 +1,19 @@
import fs from "fs";
import path from "path";
import { FileGuidePage } from "@/components/legal/file-guide-page";
import { pageMetadata } from "@/lib/site";
export const metadata = pageMetadata({
title: "File Upload Guide",
description:
"Step-by-step export instructions for KiCad, Altium, OrCAD, Cadence Allegro, Siemens Xpedition, EasyEDA, and Autodesk EAGLE — netlists, BOMs, and datasheets ready for Pinscope.",
path: "/file-guide",
});
export default function Page() {
const content = fs.readFileSync(
path.join(process.cwd(), "content", "file-guide.md"),
"utf-8",
);
return <FileGuidePage content={content} />;
}
@@ -0,0 +1,72 @@
"use client";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { useOptionalAuth } from "@/hooks/use-optional-auth";
import { Button } from "@/components/ui/button";
export function NavAuthCluster() {
const { isSignedIn } = useOptionalAuth();
if (isSignedIn) {
return (
<Link href="/dashboard">
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-500 text-white border-0"
>
Dashboard
<ArrowRight className="h-3.5 w-3.5" />
</Button>
</Link>
);
}
return (
<>
<Link href="/login">
<Button variant="ghost" size="sm">
Sign in
</Button>
</Link>
<Link href="/login">
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-500 text-white border-0"
>
Get started
</Button>
</Link>
</>
);
}
export function PrimaryCta() {
const { isSignedIn } = useOptionalAuth();
const href = isSignedIn ? "/dashboard" : "/login";
const label = isSignedIn ? "Go to Dashboard" : "Start for free";
return (
<Link href={href}>
<Button
size="lg"
className="bg-blue-600 hover:bg-blue-500 text-white border-0 px-8 text-base h-12"
>
{label}
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
);
}
export function PricingCta() {
const { isSignedIn } = useOptionalAuth();
return (
<Link href={isSignedIn ? "/billing" : "/login"}>
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-500 text-white border-0 gap-1.5"
>
{isSignedIn ? "Buy credits" : "Get started"}
<ArrowRight className="h-3.5 w-3.5" />
</Button>
</Link>
);
}
+11
View File
@@ -0,0 +1,11 @@
export default function MarketingLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="min-h-full flex flex-col">
{children}
</div>
);
}
+450
View File
@@ -0,0 +1,450 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { Cpu, Shield, Lock, ServerCog, Users, GitBranch } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme/theme-toggle";
import { APP_VERSION_DATE } from "@/lib/version";
import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/site";
import {
PRICING_JSON_LD_OFFERS,
PRICING_NAV_LINK,
PricingSection,
} from "@/components/marketing/pricing-section";
import { NavAuthCluster, PrimaryCta } from "./landing-cta";
// Landing page uses the root layout's metadata as-is so the file-convention
// OG/Twitter images (which Next merges only when no openGraph override exists)
// remain in the head. Canonical is already "/" from the root.
export const metadata: Metadata = {};
const LATEST_CHANGE_LABEL = APP_VERSION_DATE
? new Date(`${APP_VERSION_DATE}T00:00:00Z`).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
timeZone: "UTC",
})
: null;
const STEPS = [
{ label: "Upload", detail: "Your design files" },
{ label: "Read", detail: "Every datasheet" },
{ label: "Verify", detail: "Pin by pin" },
{ label: "Report", detail: "Findings and fixes" },
];
const FEATURES = [
{
title: "Every finding, traceable",
description:
"Each recommendation links straight to the datasheet page and figure that backs it up. No black box — verify the reasoning, not just the verdict.",
placeholder: "Datasheet grounded recommendations",
image: "/datasheet.gif",
},
{
title: "Auto-documentation",
description:
"For mission-critical projects. The artifacts your reviewers expect — generated on every run, ready to share.",
placeholder: "Derating documentation",
image: "/derating.png",
},
];
const EDA_TOOLS = [
{ name: "KiCad", logo: "/eda-logos/kicad.svg" },
{ name: "Altium Designer", logo: "/eda-logos/altium.svg" },
{ name: "OrCAD", logo: "/eda-logos/orcad.svg" },
{ name: "Cadence Allegro", logo: "/eda-logos/cadence.svg" },
{ name: "Siemens Xpedition", logo: "/eda-logos/siemens.svg" },
{ name: "EasyEDA", logo: "/eda-logos/easyeda.svg" },
{ name: "Autodesk EAGLE", logo: "/eda-logos/autodesk.svg" },
];
const jsonLd = [
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: SITE_NAME,
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
description: SITE_DESCRIPTION,
url: SITE_URL,
image: `${SITE_URL}/opengraph-image`,
screenshot: `${SITE_URL}/report.png`,
...(PRICING_JSON_LD_OFFERS ? { offers: PRICING_JSON_LD_OFFERS } : {}),
publisher: {
"@type": "Organization",
name: "Faradworks",
url: "https://faradworks.com",
},
},
{
"@context": "https://schema.org",
"@type": "Organization",
name: "Faradworks",
url: "https://faradworks.com",
logo: `${SITE_URL}/faradworks-logo-white.png`,
sameAs: [
"https://www.linkedin.com/company/faradworks",
"https://x.com/getFaradWorks",
],
address: {
"@type": "PostalAddress",
streetAddress: "33 W 17th St",
addressLocality: "New York",
addressRegion: "NY",
addressCountry: "US",
},
contactPoint: {
"@type": "ContactPoint",
contactType: "customer support",
email: "dev@faradworks.com",
},
},
];
export default function LandingPage() {
return (
<div className="flex flex-col min-h-full">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* ── Nav ── */}
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-lg">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-6">
<Link href="/" className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-blue-500" />
<span className="text-sm font-semibold tracking-tight">
Pinscope
</span>
</Link>
<nav className="hidden sm:flex items-center gap-6 text-sm text-muted-foreground">
<a
href="#features"
className="hover:text-foreground transition-colors"
>
Features
</a>
<a
href="#security"
className="hover:text-foreground transition-colors"
>
Security
</a>
{PRICING_NAV_LINK && (
<a
href={PRICING_NAV_LINK.href}
className="hover:text-foreground transition-colors"
>
{PRICING_NAV_LINK.label}
</a>
)}
<Link
href="/file-guide"
className="hover:text-foreground transition-colors"
>
Docs
</Link>
<Link
href="/contact"
className="hover:text-foreground transition-colors"
>
Contact
</Link>
</nav>
<div className="flex items-center gap-3">
<ThemeToggle />
<NavAuthCluster />
</div>
</div>
</header>
{/* ── Hero ── */}
<section className="mx-auto max-w-5xl px-6 pt-24 sm:pt-36 pb-12">
<h1 className="font-headline text-[2.75rem] leading-[1.08] sm:text-6xl lg:text-7xl tracking-tight animate-fade-up">
Ship hardware that
<br className="hidden lg:block" /> works the
<br className="hidden lg:block" /> first time
</h1>
<p className="mt-6 text-lg sm:text-xl text-muted-foreground max-w-xl leading-relaxed animate-fade-up [animation-delay:100ms]">
Pinscope reviews your schematic against every datasheet and
catches the errors that would otherwise surface at bring-up.
</p>
<div className="mt-8 flex flex-col items-start gap-4 animate-fade-up [animation-delay:200ms]">
<PrimaryCta />
{LATEST_CHANGE_LABEL && (
<Link
href="/changelog"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<GitBranch className="h-4 w-4" />
Read Latest Changes: {LATEST_CHANGE_LABEL}
</Link>
)}
</div>
</section>
{/* ── Hero product screenshot ── */}
<section className="mx-auto max-w-5xl px-6 pb-24 animate-fade-up [animation-delay:350ms]">
<div className="rounded-xl border border-border overflow-hidden bg-card/40">
<Image
src="/report.png"
alt="Pinscope validation report"
width={2400}
height={1500}
className="w-full h-auto"
priority
unoptimized
/>
</div>
</section>
{/* ── Supported EDA tools ── */}
<section className="border-t border-border/50">
<div className="mx-auto max-w-6xl px-6 py-14">
<p className="text-center text-xs font-mono uppercase tracking-[0.18em] text-muted-foreground">
Works with your EDA tool
</p>
<div className="mt-8 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-x-8 gap-y-10 items-center">
{EDA_TOOLS.map((tool) => (
<div
key={tool.name}
className="flex flex-col items-center justify-center gap-2"
title={tool.name}
>
<Image
src={tool.logo}
alt={`${tool.name} logo`}
width={140}
height={40}
className="h-7 sm:h-8 w-auto object-contain [filter:brightness(0)] dark:[filter:brightness(0)_invert(1)] opacity-50 hover:opacity-90 transition-opacity"
unoptimized
/>
<span className="text-[11px] text-muted-foreground/80">
{tool.name}
</span>
</div>
))}
</div>
<p className="mt-10 text-center text-xs text-muted-foreground">
<Link href="/file-guide" className="hover:text-foreground transition-colors">
See export instructions for each tool
</Link>
</p>
</div>
</section>
{/* ── Pipeline ── */}
<section className="border-y border-border/50">
<div className="mx-auto max-w-3xl px-6 py-16">
{/* Desktop: horizontal connected steps */}
<div className="hidden sm:flex items-start justify-between relative">
<div className="absolute top-3 left-[12.5%] right-[12.5%] h-px bg-border" />
{STEPS.map((s, i) => (
<div
key={s.label}
className="relative flex flex-col items-center text-center flex-1"
>
<div className="h-6 w-6 rounded-full bg-background border border-border flex items-center justify-center text-[11px] font-mono text-muted-foreground z-10">
{i + 1}
</div>
<span className="mt-3 text-sm font-medium">{s.label}</span>
<span className="mt-1 text-xs text-muted-foreground">
{s.detail}
</span>
</div>
))}
</div>
{/* Mobile: vertical */}
<div className="sm:hidden flex flex-col gap-6 relative pl-8">
<div className="absolute left-[11px] top-3 bottom-3 w-px bg-border" />
{STEPS.map((s, i) => (
<div key={s.label} className="relative flex items-start gap-4">
<div className="absolute -left-8 h-6 w-6 rounded-full bg-background border border-border flex items-center justify-center text-[11px] font-mono text-muted-foreground z-10">
{i + 1}
</div>
<div>
<span className="text-sm font-medium">{s.label}</span>
<span className="block text-xs text-muted-foreground mt-0.5">
{s.detail}
</span>
</div>
</div>
))}
</div>
</div>
</section>
{/* ── Features — alternating text + image ── */}
<section id="features" className="scroll-mt-16">
{FEATURES.map((f, i) => (
<div
key={f.title}
className={
i < FEATURES.length - 1 ? "border-b border-border/30" : ""
}
>
<div className="mx-auto max-w-6xl px-6 py-20 sm:py-24 grid grid-cols-1 lg:grid-cols-2 gap-10 lg:gap-16 items-center">
<div className={i % 2 === 1 ? "lg:order-2" : ""}>
<h2 className="font-headline text-3xl sm:text-4xl tracking-tight leading-tight">
{f.title}
</h2>
<p className="mt-4 text-muted-foreground leading-relaxed">
{f.description}
</p>
</div>
<div
className={`rounded-xl border border-border bg-card/40 aspect-[4/3] flex items-center justify-center overflow-hidden ${
i % 2 === 1 ? "lg:order-1" : ""
}`}
>
{f.image ? (
<Image
src={f.image}
alt={f.placeholder}
width={800}
height={600}
className="w-full h-full object-cover"
unoptimized
/>
) : (
<p className="text-sm text-muted-foreground/60">
{f.placeholder}
</p>
)}
</div>
</div>
</div>
))}
</section>
{/* ── Security ── */}
<section id="security" className="border-y border-border/50 scroll-mt-16">
<div className="mx-auto max-w-5xl px-6 py-20 sm:py-24">
<h2 className="font-headline text-3xl sm:text-4xl tracking-tight text-center">
Your designs stay yours
</h2>
<p className="mt-3 text-muted-foreground text-center max-w-lg mx-auto">
Hardware IP is sensitive. Pinscope is built to keep it that way.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-12">
{[
{
icon: Lock,
title: "Encrypted end-to-end",
detail: "AES-256 at rest, TLS 1.3 in transit.",
},
{
icon: Shield,
title: "Never used for training",
detail: "Your files never train anyone's model. Zero retention from our AI providers.",
},
{
icon: ServerCog,
title: "SOC 2 infrastructure",
detail: "Audit logging, continuous monitoring, and incident response.",
},
{
icon: Users,
title: "Role-based access",
detail: "Owner and collaborator roles. You decide who sees what.",
},
].map((item) => (
<div
key={item.title}
className="rounded-xl border border-border bg-card/40 p-5 flex flex-col gap-3"
>
<item.icon className="h-5 w-5 text-emerald-500" />
<h3 className="text-sm font-semibold">{item.title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{item.detail}
</p>
</div>
))}
</div>
</div>
</section>
<PricingSection />
{/* ── Final CTA ── */}
<section className="border-t border-border/50">
<div className="mx-auto max-w-3xl px-6 py-20 sm:py-24 text-center">
<h2 className="font-headline text-3xl sm:text-4xl tracking-tight">
Stop reviewing schematics by hand
</h2>
<p className="mt-4 text-muted-foreground max-w-md mx-auto">
Upload your first design. Get a full review in minutes.
</p>
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<PrimaryCta />
<Link href="/contact">
<Button size="lg" variant="outline" className="px-8 text-base h-12">
Contact us
</Button>
</Link>
</div>
<p className="mt-4 text-xs text-muted-foreground">
Questions? Reach us at{" "}
<a
href="mailto:dev@faradworks.com"
className="text-foreground hover:underline"
>
dev@faradworks.com
</a>
</p>
</div>
</section>
{/* ── Footer ── */}
<footer className="border-t border-border/50 mt-auto">
<div className="mx-auto max-w-6xl px-6 py-10">
<div className="flex flex-col gap-8 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-4">
<Image
src="/faradworks-logo-white.png"
alt="Faradworks"
width={569}
height={230}
className="h-8 w-auto self-start opacity-90 invert dark:invert-0"
/>
<address className="text-xs not-italic leading-relaxed text-muted-foreground">
33 W 17th St
<br />
New York, NY
</address>
</div>
<div className="flex flex-col items-start gap-4 sm:items-end">
<a
href="https://www.linkedin.com/company/faradworks"
target="_blank"
rel="noopener noreferrer"
aria-label="Faradworks on LinkedIn"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<svg
viewBox="0 0 24 24"
fill="currentColor"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.95v5.66H9.34V9h3.42v1.56h.05a3.75 3.75 0 0 1 3.37-1.85c3.6 0 4.27 2.37 4.27 5.46v6.28zM5.34 7.43a2.07 2.07 0 1 1 0-4.13 2.07 2.07 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45zM22.23 0H1.77C.79 0 0 .77 0 1.72v20.56C0 23.23.79 24 1.77 24h20.46c.98 0 1.77-.77 1.77-1.72V1.72C24 .77 23.21 0 22.23 0z" />
</svg>
</a>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<Link href="/contact" className="hover:text-foreground transition-colors">Contact</Link>
<Link href="/changelog" className="hover:text-foreground transition-colors">Changelog</Link>
<Link href="/privacy" className="hover:text-foreground transition-colors">Privacy</Link>
<Link href="/terms" className="hover:text-foreground transition-colors">Terms</Link>
</div>
<span className="text-xs text-muted-foreground">&copy; {new Date().getFullYear()} Faradworks</span>
</div>
</div>
</div>
</footer>
</div>
);
}
@@ -0,0 +1,19 @@
import fs from "fs";
import path from "path";
import { LegalPage } from "@/components/legal/legal-page";
import { pageMetadata } from "@/lib/site";
export const metadata = pageMetadata({
title: "Privacy Policy",
description:
"How Pinscope collects, stores, and protects the schematics, datasheets, and BOMs you upload.",
path: "/privacy",
});
export default function PrivacyPage() {
const content = fs.readFileSync(
path.join(process.cwd(), "content", "privacy.md"),
"utf-8"
);
return <LegalPage content={content} />;
}
@@ -0,0 +1,19 @@
import fs from "fs";
import path from "path";
import { LegalPage } from "@/components/legal/legal-page";
import { pageMetadata } from "@/lib/site";
export const metadata = pageMetadata({
title: "Terms of Service",
description:
"Terms governing your use of Pinscope, including account, payment, and acceptable-use rules.",
path: "/terms",
});
export default function TermsPage() {
const content = fs.readFileSync(
path.join(process.cwd(), "content", "terms.md"),
"utf-8"
);
return <LegalPage content={content} />;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+146
View File
@@ -0,0 +1,146 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace;
--font-heading: var(--font-sans);
--font-headline: var(--font-dm-serif-display), Georgia, serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-up {
animation: fade-up 0.7s ease-out both;
}
+122
View File
@@ -0,0 +1,122 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono, DM_Serif_Display } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme/theme-provider";
import { ClerkThemeProvider } from "@/components/theme/clerk-theme-provider";
import { RedditPixel } from "@/components/analytics/reddit-pixel";
import {
SITE_DESCRIPTION,
SITE_NAME,
SITE_TAGLINE,
SITE_URL,
TWITTER_HANDLE,
} from "@/lib/site";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const dmSerifDisplay = DM_Serif_Display({
variable: "--font-dm-serif-display",
weight: "400",
subsets: ["latin"],
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: `${SITE_NAME}${SITE_TAGLINE}`,
template: `%s · ${SITE_NAME}`,
},
description: SITE_DESCRIPTION,
applicationName: SITE_NAME,
authors: [{ name: "Faradworks", url: "https://faradworks.com" }],
creator: "Faradworks",
publisher: "Faradworks",
category: "technology",
keywords: [
"schematic review",
"schematic validation",
"PCB design review",
"datasheet review",
"hardware design verification",
"EDA",
"KiCad",
"Altium",
"OrCAD",
"Cadence",
"Siemens Xpedition",
"EasyEDA",
"EAGLE",
],
alternates: { canonical: "/" },
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
},
},
openGraph: {
type: "website",
siteName: SITE_NAME,
url: "/",
title: `${SITE_NAME}${SITE_TAGLINE}`,
description: SITE_DESCRIPTION,
locale: "en_US",
},
twitter: {
card: "summary_large_image",
site: TWITTER_HANDLE,
creator: TWITTER_HANDLE,
title: `${SITE_NAME}${SITE_TAGLINE}`,
description: SITE_DESCRIPTION,
},
icons: {
icon: [
{ url: "/favicon_io/favicon.ico", sizes: "any" },
{ url: "/favicon_io/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/favicon_io/favicon-32x32.png", sizes: "32x32", type: "image/png" },
],
apple: "/favicon_io/apple-touch-icon.png",
},
manifest: "/favicon_io/site.webmanifest",
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
],
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} ${dmSerifDisplay.variable} h-full antialiased`}
>
<body className="h-full">
<ThemeProvider>
<ClerkThemeProvider>{children}</ClerkThemeProvider>
</ThemeProvider>
<RedditPixel />
</body>
</html>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { ImageResponse } from "next/og";
export const alt = "Pinscope — Agentic schematic validation";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function Image() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "72px",
background:
"radial-gradient(at 30% 20%, #15233f 0%, #0a0a0a 55%, #050505 100%)",
color: "#fafafa",
fontFamily: "sans-serif",
}}
>
{/* Brand row */}
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="#3b82f6"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="4" y="4" width="16" height="16" rx="2" />
<rect x="9" y="9" width="6" height="6" />
<path d="M9 2v2" />
<path d="M15 2v2" />
<path d="M9 20v2" />
<path d="M15 20v2" />
<path d="M20 9h2" />
<path d="M20 15h2" />
<path d="M2 9h2" />
<path d="M2 15h2" />
</svg>
<div
style={{
fontSize: 36,
fontWeight: 600,
letterSpacing: "-0.01em",
}}
>
Pinscope
</div>
</div>
{/* Main copy */}
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<div
style={{
fontSize: 88,
fontWeight: 600,
lineHeight: 1.05,
letterSpacing: "-0.03em",
maxWidth: 980,
}}
>
Ship hardware that works the first time.
</div>
<div
style={{
fontSize: 30,
lineHeight: 1.3,
color: "#a1a1aa",
maxWidth: 880,
}}
>
Datasheet-grounded schematic review. Catches the errors that
would otherwise surface at bring-up.
</div>
</div>
{/* Footer row */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: 22,
color: "#71717a",
borderTop: "1px solid #27272a",
paddingTop: 24,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
KiCad · Altium · OrCAD · Cadence · Siemens · EasyEDA · EAGLE
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
pinscope.ai
</div>
</div>
</div>
),
{ ...size },
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: [
"/admin",
"/admin/",
"/dashboard",
"/dashboard/",
"/project",
"/project/",
"/credits",
"/credits/",
"/billing",
"/billing/",
"/api/",
],
},
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
};
}
+48
View File
@@ -0,0 +1,48 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/site";
import { APP_VERSION_DATE } from "@/lib/version";
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = APP_VERSION_DATE
? new Date(`${APP_VERSION_DATE}T00:00:00Z`)
: new Date();
return [
{
url: `${SITE_URL}/`,
lastModified,
changeFrequency: "weekly",
priority: 1,
},
{
url: `${SITE_URL}/changelog`,
lastModified,
changeFrequency: "weekly",
priority: 0.7,
},
{
url: `${SITE_URL}/file-guide`,
lastModified,
changeFrequency: "monthly",
priority: 0.7,
},
{
url: `${SITE_URL}/contact`,
lastModified,
changeFrequency: "yearly",
priority: 0.5,
},
{
url: `${SITE_URL}/privacy`,
lastModified,
changeFrequency: "yearly",
priority: 0.3,
},
{
url: `${SITE_URL}/terms`,
lastModified,
changeFrequency: "yearly",
priority: 0.3,
},
];
}
+1
View File
@@ -0,0 +1 @@
export { default, alt, size, contentType } from "./opengraph-image";