"use client"
import * as React from "react"
// Relative import on purpose: the 21st CLI rewrites it to the published path (`@/components/ui/<slug>`)
// and to a temporary path during `21st render`. An absolute "@/…" import breaks the render build.
import {
ImportPatchbay,
type PatchColumn,
type PatchField,
type PatchMapping,
} from "@/components/ui/import-patchbay"
// A real CRM export, messy the way real exports are: a renamed column (`col_7`), a synonym
// (`mobile`), an abbreviation (`MRR (USD)`), and a second date column that makes the signup date
// genuinely ambiguous — which is why the matcher patches it dashed instead of pretending to be sure.
// The order is deliberate too: file order is never schema order, which is the whole reason this
// screen exists, so the cables cross instead of running in flat parallel lines.
const COLUMNS: PatchColumn[] = [
{ id: "c-first", name: "first_name", samples: ["Priya", "Jonas"] },
{ id: "c-last", name: "last_name", samples: ["Raman", "Weber"] },
{ id: "c-company", name: "Company / Org", samples: ["Northwind", "Lumen Labs"] },
{ id: "c-7", name: "col_7", samples: ["2026-03-14", "2026-01-09"] },
{
id: "c-email",
name: "Email Address",
samples: ["priya@northwind.io", "j.weber@lumen.dev"],
},
{ id: "c-mobile", name: "mobile", samples: ["+1 415 555 0132", "+49 30 5550 118"] },
{ id: "c-mrr", name: "MRR (USD)", samples: ["$1,240", "$380"] },
{ id: "c-touch", name: "last_touch", samples: ["2026-08-30", "2026-08-27"] },
]
const FIELDS: PatchField[] = [
{ id: "email", label: "Email", required: true, kind: "email" },
{ id: "firstName", label: "First name", required: true, kind: "text" },
{ id: "lastName", label: "Last name", kind: "text" },
{ id: "company", label: "Company", kind: "text", synonyms: ["organisation", "account"] },
{ id: "phone", label: "Phone", kind: "phone" },
{
id: "signedUp",
label: "Signed up",
required: true,
kind: "date",
hint: "when they registered",
},
{ id: "monthlyValue", label: "Monthly value", kind: "currency" },
]
// What someone would have patched by hand before giving up: the four columns whose names say it all.
const OBVIOUS: PatchMapping = {
email: "c-email",
firstName: "c-first",
lastName: "c-last",
company: "c-company",
}
// Module-level `settings` + a default export that accepts them as props gives this demo a live
// controls panel on 21st.dev. Signature knobs first, then look, then copy.
const settings = {
// Off starts from a bare board, so the button wires all seven at once.
startPatched: true,
// Score a pair needs before a cable is proposed at all.
threshold: 0.4,
// Score a pair needs to be drawn solid rather than dashed and counted for review.
sureThreshold: 0.68,
// How far a cable droops, as a fraction of the gutter width.
sag: 0.18,
gutterWidth: 96,
rowHeight: 46,
showSamples: true,
showKinds: true,
fileName: "contacts-q3.csv",
// One line at every width the cover is captured at: the card is the photograph, the headline
// only has to name the screen.
headline: "The import step, already wired.",
subline:
"Auto-patch reads the values, not just the header names, so col_7 full of dates finds Signed up.",
}
export default function Demo(props: Partial<typeof settings>) {
const s = { ...settings, ...props }
const [mapping, setMapping] = React.useState<PatchMapping>(s.startPatched ? OBVIOUS : {})
const [imported, setImported] = React.useState<string[] | null>(null)
React.useEffect(() => {
setMapping(s.startPatched ? OBVIOUS : {})
setImported(null)
}, [s.startPatched])
return (
<div className="bg-background flex min-h-[max(560px,100svh)] w-full flex-col items-center justify-center px-5 py-10 sm:px-8">
<div className="flex w-full max-w-3xl flex-col items-center">
<h1 className="text-foreground text-center text-3xl font-semibold tracking-tight text-balance sm:text-5xl">
{s.headline}
</h1>
<p className="text-muted-foreground mt-3 max-w-3xl text-center text-sm text-pretty sm:text-base">
{s.subline}
</p>
<div className="mt-8 w-full">
<ImportPatchbay
columns={COLUMNS}
fields={FIELDS}
value={mapping}
onValueChange={setMapping}
onConfirm={(m) =>
setImported(
COLUMNS.filter((c) => !Object.values(m).includes(c.id)).map((c) => c.name)
)
}
fileName={s.fileName}
rowCount={1284}
threshold={s.threshold}
sureThreshold={s.sureThreshold}
sag={s.sag}
gutterWidth={s.gutterWidth}
rowHeight={s.rowHeight}
showSamples={s.showSamples}
showKinds={s.showKinds}
confirmLabel="Import 1,284 contacts"
/>
</div>
{/* Rendered only once it has something to say, so it costs the cover no height. */}
{imported ? (
<p aria-live="polite" className="text-muted-foreground mt-3 text-center text-xs">
{`Imported 1,284 contacts. Skipped ${imported.length} ` +
`column${imported.length === 1 ? "" : "s"}: ${imported.join(", ")}.`}
</p>
) : null}
</div>
</div>
)
}