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:
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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'll email you when it'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 ·{" "}
|
||||
{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’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>© {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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export default function MarketingLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div className="min-h-full flex flex-col">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">© {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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default, alt, size, contentType } from "./opengraph-image";
|
||||
Reference in New Issue
Block a user