spb/tools-spboucher-ai
Public
1import type { Metadata } from "next";2import Link from "next/link";3import { getToolsBySlugs, type ToolDetail } from "@/lib/public/queries";4import { Breadcrumbs } from "@/components/breadcrumbs";5import { label, fmtDate } from "@/lib/utils";6import { CompareSearch } from "@/components/compare-search";7import { X } from "lucide-react";8export const dynamic = "force-dynamic";9export const metadata: Metadata = { title: "Compare tools", description: "Side-by-side comparison of two to five tools: documented facts (license, pricing model, platforms, interfaces) separated from editorial assessment.", robots: { index: false, follow: true } };1011export default async function Compare({ searchParams }: { searchParams: Promise<{ tools?: string }> }) {12 const slugs = ((await searchParams).tools ?? "").split(",").map((s) => s.trim()).filter(Boolean).slice(0, 5);13 const tools = await getToolsBySlugs(slugs);14 const ids = tools.map((t) => t.slug);15 const facts: { k: string; v: (t: ToolDetail) => React.ReactNode }[] = [16 { k: "Vendor", v: (t) => t.vendor?.name ?? "—" }, { k: "Product type", v: (t) => label(t.productType) }, { k: "Status", v: (t) => label(t.productStatus) },17 { k: "Source model", v: (t) => label(t.sourceModel) }, { k: "License", v: (t) => t.licenseSpdx ?? (t.sourceModel === "proprietary" ? "Proprietary" : "Not verified") }, { k: "Pricing model", v: (t) => t.pricingModel.map(label).join(", ") },18 { k: "Runs", v: (t) => t.executionModes.map(label).join(", ") }, { k: "Self-hostable", v: (t) => (t.selfHostable === null ? "Unknown" : t.selfHostable ? "Yes" : "No") }, { k: "Interfaces", v: (t) => t.interfaces.map(label).join(", ") },19 { k: "Operating systems", v: (t) => t.operatingSystems.map(label).join(", ") || "—" }, { k: "Languages", v: (t) => t.languages.join(", ") || "—" }, { k: "Data formats", v: (t) => t.dataFormats.join(", ") || "—" },20 { k: "Integrations", v: (t) => t.integrations.join(", ") || "—" }, { k: "Sources cited", v: (t) => String(t.sources.length) }, { k: "Links verified", v: (t) => (t.linkState === "ok" ? fmtDate(t.checkedAt) : label(t.linkState)) },21 ];22 const editorial: { k: string; v: (t: ToolDetail) => string[] }[] = [{ k: "Capabilities", v: (t) => t.capabilities }, { k: "Strengths", v: (t) => t.advantages }, { k: "Limitations", v: (t) => t.limitations }, { k: "Best for", v: (t) => t.audiences }];23 return (24 <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6">25 <Breadcrumbs items={[{ label: "Compare" }]} />26 <h1 className="display h1 mt-3">Compare tools</h1>27 <p className="mt-2 max-w-2xl text-fg-2">Pick two to five tools. The first table lists documented facts from primary sources; the second is editorial assessment. Nothing here implies measured performance.</p>28 <div className="mt-5 flex flex-wrap items-center gap-2">29 {tools.map((t) => <Link key={t.slug} href={`/compare?tools=${ids.filter((s) => s !== t.slug).join(",")}`} rel="nofollow" className="chip chip-accent">{t.name} <X className="h-3 w-3" aria-hidden /><span className="sr-only">remove</span></Link>)}30 {tools.length < 5 && <CompareSearch current={ids} />}31 </div>32 {tools.length < 2 ? (33 <p className="card mt-8 p-8 text-center text-fg-2">Add at least two tools to compare. Tip: every tool page has a “Compare” button and lists its alternatives.</p>34 ) : (35 <>36 <h2 className="display h2 mt-10 mb-3">Documented facts</h2>37 <div className="overflow-x-auto rounded-2xl border border-line bg-bg-2" tabIndex={0} role="region" aria-label="Documented facts table">38 <table className="table min-w-[48rem]"><caption className="sr-only">Documented facts for {tools.map((t) => t.name).join(", ")}</caption>39 <thead><tr><th scope="col">Fact</th>{tools.map((t) => <th key={t.slug} scope="col"><Link href={`/tools/${t.slug}`} className="text-fg hover:text-accent normal-case tracking-normal text-sm">{t.name}</Link></th>)}</tr></thead>40 <tbody>{facts.map((f) => <tr key={f.k}><th scope="row" className="!static !bg-transparent font-medium text-fg-2 normal-case tracking-normal">{f.k}</th>{tools.map((t) => <td key={t.slug}>{f.v(t)}</td>)}</tr>)}</tbody>41 </table>42 </div>43 <h2 className="display h2 mt-10 mb-3">Editorial assessment</h2>44 <div className="overflow-x-auto rounded-2xl border border-line bg-bg-2" tabIndex={0} role="region" aria-label="Editorial assessment table">45 <table className="table min-w-[48rem]"><caption className="sr-only">Editorial assessment</caption>46 <thead><tr><th scope="col">Aspect</th>{tools.map((t) => <th key={t.slug} scope="col" className="normal-case tracking-normal text-sm text-fg">{t.name}</th>)}</tr></thead>47 <tbody>{editorial.map((e) => <tr key={e.k}><th scope="row" className="!static !bg-transparent font-medium text-fg-2 normal-case tracking-normal">{e.k}</th>{tools.map((t) => <td key={t.slug}><ul className="list-disc space-y-1 pl-4 text-fg-2">{e.v(t).map((x) => <li key={x}>{x}</li>)}</ul></td>)}</tr>)}48 <tr><th scope="row" className="!static !bg-transparent font-medium text-fg-2 normal-case tracking-normal">Summary</th>{tools.map((t) => <td key={t.slug} className="text-fg-2">{t.summary}</td>)}</tr>49 </tbody>50 </table>51 </div>52 </>53 )}54 </div>55 );56}57