Skip to contentVibraUI
Shared pagesinstalls at /import

CSV import

A four-step importer: drop a CSV, map each of its columns to a field, look at the first rows as the mapping reads them, and write the rest — with every refused row named by its line number.

Open the live page

csv.ts is the whole parser: one pass over the characters rather than a split on commas, which is what lets a quoted field hold commas, newlines and doubled quotes; CRLF and LF are both one break, a spreadsheet app's byte order mark is stripped, a short row is padded to the header's width and a long one is kept whole. It has no dependency and its own test file — fifty lines a reader can see all of are worth more here than a package. The file is read in the browser and never uploaded: a reader deciding what a column means should not wait for a round trip between selects, and the wizard says so in its footer. Columns are auto-matched by name — case, spaces and punctuation normalised away, with a small alias list per field — and a field can only be claimed once, so two columns cannot both quietly become Email. Preview is blocked while a required field has no column, and the reason is a live status line beside the button rather than a disabled control with no explanation. Only the last step crosses to the server: importCustomers validates each row and writes it through db.customers.create, refusing a bad row by its line number instead of dropping it and never letting one bad row stop the others — the summary carries imported, skipped, and one line per refusal, and Result is only ok: false when there was nothing to attempt at all. Line numbers count the header as line 1, which is the number the reader's own spreadsheet shows. Composes AppShell, PageHeader, Widget, Stepper, FileDropzone, NativeSelect, SimpleTable, Callout, Button and AsyncButton.

Preview

Install

npx shadcn@latest add @vibra/data-import

Needs the @vibra registry in your components.json — set it up once.

Source

app/import/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { ImportWizard } from "./components/import-wizard"
import { currentUser, lastUpdated, shellNotifications } from "./data"
import { NAV, ROUTE } from "./nav"

/**
 * The importer. A server component around one island: the file is read, mapped
 * and previewed entirely in the browser, and only the last step — writing the
 * rows — crosses back to the server, as an action.
 */
export default function ImportPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTE}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Import"
        description="Bring a customer list in from a spreadsheet, a column at a time."
        meta={lastUpdated()}
      />

      <ImportWizard />
    </AppShell>
  )
}
app/import/nav.ts
import { type NavConfig } from "@/lib/nav-config"

/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/import"

/**
 * This product's navigation, as plain data. AppShell resolves the icon names
 * and works out which item is current from the pathname, so nothing here is a
 * component and nothing here says "I am the page you are on".
 */
export const NAV: NavConfig = {
  brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
  groups: [
    {
      label: "Workspace",
      items: [{ title: "Overview", href: "/saas", icon: "layout-dashboard" }],
    },
    {
      label: "Data",
      items: [
        { title: "Customers", href: "/ecommerce/customers", icon: "users" },
        { title: "Orders", href: "/ecommerce/orders", icon: "shopping-cart" },
        { title: "Invoices", href: "/finance/invoices", icon: "receipt" },
        { title: "Audit log", href: "/settings/audit-log", icon: "list" },
        { title: "Import", href: "/import", icon: "file-text" },
      ],
    },
  ],
  // Pinned under the groups, the way the secondary links were.
  footer: [
    { title: "Settings", href: "/settings", icon: "settings" },
    { title: "Support", href: "/support", icon: "life-buoy" },
  ],
}
app/import/data.ts
/**
 * What this page reads. The importer itself reads nothing from `db` — the file
 * a reader brings is the data — so this is only what the shell around it needs:
 * who is signed in, what is in the bell, and how many accounts the store holds
 * before anything is added to it. "Now" is `REFERENCE_DATE`.
 */
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Member } from "@/lib/sample-data"

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

// Fixed to UTC so the line reads the same wherever the page is rendered.
const UPDATED_AT = new Intl.DateTimeFormat("en-US", {
  dateStyle: "medium",
  timeStyle: "short",
  hourCycle: "h23",
  timeZone: "UTC",
})

/** How many accounts the store held when the page was rendered. */
export function lastUpdated(): string {
  return `${db.customers.all().length} accounts · ${UPDATED_AT.format(REFERENCE_DATE)} UTC`
}
app/import/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { REFERENCE_DATE, db, isRecord, isWhole, type Customer, type Result } from "@/lib/sample-data"
import { isEmail } from "@/lib/validation"

import { PLANS } from "./fields"

/** One row as the mapping left it: a value per target field, everything a string. */
export type ImportRow = {
  /** The line the row was on in the file, header counted, so a refusal can name it. */
  line: number
  values: Record<string, string>
}

/** Why one row was not written. */
export type ImportRefusal = { line: number; message: string }

/** What an import did. */
export type ImportSummary = {
  imported: number
  skipped: number
  refusals: ImportRefusal[]
}

// Whoever owns the workspace holds an imported account until someone reassigns
// it; a row arriving with no owner at all would be invisible on every page that
// groups customers by the teammate who looks after them. Read per import, never
// held at module scope.
const importer = () => db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]

/** The longest a cell may run, by field — what the customers table draws, not what a file can hold. */
const MAX_LENGTH: Record<string, number> = { name: 120, email: 254, company: 120, country: 60, plan: 20, seats: 7, mrrCents: 20 }

/** The most an imported account can pay a month, in cents ($100,000,000), and the most seats it holds. */
const MAX_MRR_CENTS = 10_000_000_000
const MAX_SEATS = 1_000_000

/** One cell as text: "" for a cell the row does not have, or one that is not text. */
function cell(values: Record<string, unknown>, key: string): string {
  const value = values[key]
  return typeof value === "string" ? value.trim() : ""
}

/** The complaint about one row, or null when there is nothing wrong with it. */
function refuse(values: Record<string, unknown>): string | null {
  const name = cell(values, "name")
  const email = cell(values, "email")
  const company = cell(values, "company")
  const plan = cell(values, "plan").toLowerCase()
  const seats = cell(values, "seats")
  const mrr = cell(values, "mrrCents")

  for (const [key, max] of Object.entries(MAX_LENGTH)) {
    if (cell(values, key).length > max) return `${key === "mrrCents" ? "MRR" : key[0].toUpperCase() + key.slice(1)} runs past ${max} characters.`
  }
  if (name === "") return "Name is empty."
  if (email === "") return "Email is empty."
  if (!isEmail(email)) return `Email "${email}" is not an address.`
  if (company === "") return "Company is empty."
  if (plan !== "" && !(PLANS as string[]).includes(plan)) {
    return `Plan "${plan}" is not one of ${PLANS.join(", ")}.`
  }
  if (seats !== "" && !/^\d+$/.test(seats)) return `Seats "${seats}" is not a whole number.`
  if (seats !== "" && Number(seats) > MAX_SEATS) return `Seats "${seats}" is more than ${MAX_SEATS.toLocaleString("en-US")}.`
  if (mrr !== "" && toCents(mrr) === undefined) return `MRR "${mrr}" is not an amount up to $100,000,000.`
  return null
}

/**
 * Dollars as written in a spreadsheet — "1,840", "$1840.50" — as whole cents;
 * undefined for an amount past `MAX_MRR_CENTS`, which a row is refused for.
 */
function toCents(value: string): number | undefined {
  const cleaned = value.replace(/[^0-9.]/g, "")
  const amount = Number.parseFloat(cleaned)
  if (!Number.isFinite(amount)) return 0
  const cents = Math.round(amount * 100)
  return cents <= MAX_MRR_CENTS ? cents : undefined
}

// A single import writes rows one at a time (see below) rather than in a
// batch, so a file with no real ceiling would tie up the request for however
// long an unbounded loop over it takes. 5,000 is comfortably above any file a
// spreadsheet export produces by hand and comfortably below where that loop
// becomes the problem.
const MAX_ROWS = 5000

/**
 * Writes the mapped rows into `db.customers`, one at a time, and reports what
 * happened to each.
 *
 * A row that fails a check is refused by line number rather than dropped, and
 * one bad row never stops the others: an import that gave up halfway would
 * leave the reader with a store they cannot reason about. `Result` is only
 * `ok: false` when nothing could be attempted at all — every per-row outcome
 * is inside the summary, which is what the page renders.
 */
export async function importCustomers(rows: ImportRow[]): Promise<Result<ImportSummary>> {
  if (!Array.isArray(rows)) {
    return { ok: false, error: { code: "invalid_input", message: "Send the file's rows to import." } }
  }
  if (rows.length === 0) {
    return { ok: false, error: { code: "empty", message: "There are no rows to import." } }
  }
  if (rows.length > MAX_ROWS) {
    return {
      ok: false,
      error: {
        code: "too-many",
        message: `This file has ${rows.length} rows; import ${MAX_ROWS} or fewer at a time.`,
      },
    }
  }

  const refusals: ImportRefusal[] = []
  let imported = 0
  const owner = importer()

  for (const [index, row] of rows.entries()) {
    // The line a refusal names is the file's, or the row's place in it when
    // the row does not say.
    const line = isRecord(row) && isWhole(row.line, 1, Number.MAX_SAFE_INTEGER) ? row.line : index + 2
    const values = isRecord(row) && isRecord(row.values) ? row.values : undefined
    const complaint = values ? refuse(values) : "The row holds no values."
    if (!values || complaint) {
      refusals.push({ line, message: complaint ?? "The row holds no values." })
      continue
    }

    const seats = Number.parseInt(cell(values, "seats"), 10)
    const created = await db.customers.create({
      name: cell(values, "name"),
      email: cell(values, "email"),
      company: cell(values, "company"),
      plan: (cell(values, "plan").toLowerCase() || "free") as Customer["plan"],
      // An imported account is live from the moment it arrives; nothing in a
      // CSV says otherwise, and inventing a status would be a lie about it.
      status: "active",
      mrrCents: toCents(cell(values, "mrrCents")) ?? 0,
      seats: Number.isFinite(seats) && seats > 0 ? seats : 1,
      country: cell(values, "country") || "Unknown",
      createdAt: REFERENCE_DATE,
      lastSeenAt: REFERENCE_DATE,
      owner: owner.id,
    })

    if (created.ok) imported += 1
    else refusals.push({ line, message: created.error.message })
  }

  return { ok: true, data: { imported, skipped: refusals.length, refusals } }
}

/**
 * The other thing this page changes: signing out. A server action so the page
 * can stay a server component and still hand the shell something to call.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/import/csv.ts
/**
 * A CSV reader small enough to read, and complete enough for a spreadsheet
 * export: RFC 4180 quoting (a quoted field may hold commas, newlines and
 * doubled quotes), CRLF or LF line endings, and the byte order mark a
 * spreadsheet app puts on the front.
 *
 * It is a single pass over the characters rather than a split on commas,
 * because a split cannot tell a separator from a comma inside a quoted field —
 * and that is the bug every hand-rolled CSV reader has. No dependency: this is
 * fifty lines, and a parser the reader can see the whole of is worth more here
 * than one they have to install.
 *
 * Values are handed back exactly as they were written, whitespace included; the
 * header cells are trimmed, because a column named " email " is the same column
 * as "email" and a mapping table would otherwise offer both.
 */

/** A parsed file: the header row, and every row under it. */
export type CsvTable = { columns: string[]; rows: string[][] }

/** Every field of the file, in order, as rows of raw strings. */
function scan(text: string): string[][] {
  const rows: string[][] = []
  let row: string[] = []
  let field = ""
  let quoted = false

  for (let index = 0; index < text.length; index += 1) {
    const char = text[index]

    if (quoted) {
      if (char !== '"') {
        field += char
        continue
      }
      // A doubled quote inside a quoted field is one literal quote; a single
      // one closes the field.
      if (text[index + 1] === '"') {
        field += '"'
        index += 1
        continue
      }
      quoted = false
      continue
    }

    if (char === '"' && field === "") {
      quoted = true
      continue
    }
    if (char === ",") {
      row.push(field)
      field = ""
      continue
    }
    if (char === "\n" || char === "\r") {
      // CRLF is one break, not two.
      if (char === "\r" && text[index + 1] === "\n") index += 1
      row.push(field)
      rows.push(row)
      row = []
      field = ""
      continue
    }
    field += char
  }

  // Whatever the file ended on, unless it ended cleanly on a line break.
  if (field !== "" || row.length > 0) {
    row.push(field)
    rows.push(row)
  }
  return rows
}

/** True for a row a spreadsheet left behind: no cells, or one empty one. */
const blank = (row: string[]) => row.every((cell) => cell.trim() === "")

/**
 * The file as a header and its rows. A short row is padded to the header's
 * width so a mapping never reads past the end of it; a long one is kept whole,
 * because dropping a column silently is worse than showing one nothing maps to.
 */
export function parseCsv(text: string): CsvTable {
  const scanned = scan(text.replace(/^/, "")).filter((row) => !blank(row))
  if (scanned.length === 0) return { columns: [], rows: [] }

  const columns = scanned[0].map((cell) => cell.trim())
  const rows = scanned.slice(1).map((row) =>
    row.length >= columns.length
      ? row
      : [...row, ...Array.from({ length: columns.length - row.length }, () => "")]
  )

  return { columns, rows }
}
app/import/fields.ts
/**
 * What a CSV can be imported *into*, and how a column is matched to it.
 *
 * The importer writes `db.customers` rows, so this file is the shape of one:
 * every target field, whether it has to be filled, what it is called in the
 * mapping table, and the names a spreadsheet is likely to have used for it.
 * Nothing here reads `db` — the wizard is a client island and imports these
 * values, so the store must not be behind them.
 */
import { type Customer } from "@/lib/sample-data"

/** The customer columns this importer can fill. */
export type FieldKey = "name" | "email" | "company" | "plan" | "country" | "seats" | "mrrCents"

export type ImportField = {
  key: FieldKey
  label: string
  required: boolean
  /** What the column has to hold, said in one line under the select. */
  hint: string
  /** Names a spreadsheet is likely to have used, normalised the same way. */
  aliases: string[]
}

export const PLANS: Customer["plan"][] = ["free", "starter", "team", "enterprise"]

export const FIELDS: ImportField[] = [
  {
    key: "name",
    label: "Name",
    required: true,
    hint: "The person's own name",
    aliases: ["name", "fullname", "contact", "contactname", "person"],
  },
  {
    key: "email",
    label: "Email",
    required: true,
    hint: "One address, with an @ in it",
    aliases: ["email", "emailaddress", "mail", "workemail"],
  },
  {
    key: "company",
    label: "Company",
    required: true,
    hint: "The account the person belongs to",
    aliases: ["company", "account", "organisation", "organization", "org"],
  },
  {
    key: "plan",
    label: "Plan",
    required: false,
    hint: `One of ${PLANS.join(", ")}; anything else is refused`,
    aliases: ["plan", "tier", "subscription", "package"],
  },
  {
    key: "country",
    label: "Country",
    required: false,
    hint: "Where the account is billed",
    aliases: ["country", "region", "market", "location"],
  },
  {
    key: "seats",
    label: "Seats",
    required: false,
    hint: "A whole number; blank counts as one",
    aliases: ["seats", "seatcount", "licences", "licenses", "users"],
  },
  {
    key: "mrrCents",
    label: "MRR",
    required: false,
    hint: "Dollars a month, stored in cents",
    aliases: ["mrr", "revenue", "monthlyrevenue", "amount", "value"],
  },
]

/** The value the mapping select uses for "this column goes nowhere". */
export const UNMAPPED = ""

// Case, spaces, underscores and punctuation are how the same column is written
// in ten different exports; none of them changes which column it is.
const normalise = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, "")

/** The field a source column is called by, or null when nothing recognises it. */
export function autoMatch(column: string): FieldKey | null {
  const key = normalise(column)
  if (key === "") return null
  const hit = FIELDS.find(
    (field) => normalise(field.label) === key || field.aliases.includes(key)
  )
  return hit?.key ?? null
}

/**
 * A first mapping for a file: each column takes the field its name matches, and
 * a field is never claimed twice — the leftmost column that matches wins, which
 * is the one a reader sees first.
 */
export function initialMapping(columns: string[]): Record<number, FieldKey | null> {
  const taken = new Set<FieldKey>()

  return Object.fromEntries(
    columns.map((column, index) => {
      const match = autoMatch(column)
      if (!match || taken.has(match)) return [index, null]
      taken.add(match)
      return [index, match]
    })
  )
}

/** How many rows the preview shows before the reader commits to the rest. */
export const PREVIEW_ROWS = 5

/**
 * A file to try the importer with: ten rows, of which two are deliberately
 * wrong — one has no email at all, one has an address with no @ — so a reader
 * who has no CSV to hand still sees what a refusal looks like.
 */
export const SAMPLE_CSV = `Full name,Email Address,Company,Plan,Country,Seats,MRR
Ada Kowalski,ada.kowalski@meridian.example,Meridian Analytics,team,Poland,24,1840
Owen Brandt,owen.brandt@harborline.example,Harbor Line,starter,Ireland,6,290
Mira Nakamura,mira.nakamura@kestrel.example,Kestrel Systems,enterprise,Japan,180,14200
Luis Ferreira,luis.ferreira@northgate.example,Northgate Retail,team,Portugal,31,2110
Nina Halvorsen,nina.halvorsen@fjordworks.example,Fjordworks,starter,Norway,9,420
Tobias Keller,,Keller Instruments,team,Germany,17,1260
Priya Raman,priya.raman@sundial.example,Sundial Freight,free,India,3,0
Jonas Eriksen,jonas.eriksen,Eriksen Bakeries,starter,Denmark,4,180
Elena Costa,elena.costa@viafiore.example,Via Fiore,team,Italy,22,1520
Marcus Ortiz,marcus.ortiz@cedarpoint.example,Cedar Point Labs,enterprise,United States,96,8300
`
app/import/components/column-mapping.tsx
"use client"

import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"

import { FIELDS, UNMAPPED, type FieldKey } from "../fields"

export type Mapping = Record<number, FieldKey | null>

/**
 * One row per column in the file: what it is called there, the first value it
 * holds, and the field it goes into.
 *
 * A field can only be filled from one column, so a select offers the fields
 * nothing else has taken plus its own — otherwise two columns could both claim
 * "Email" and the second would silently win. "Do not import" is always there,
 * because a spreadsheet always has a column nothing here wants.
 */
export function ColumnMapping({
  columns,
  sample,
  mapping,
  onChange,
}: {
  columns: string[]
  /** The first row of the file, so each select is decided beside a real value. */
  sample: string[]
  mapping: Mapping
  onChange: (index: number, field: FieldKey | null) => void
}) {
  const taken = new Set(Object.values(mapping).filter((field): field is FieldKey => field !== null))

  return (
    <ul className="flex flex-col divide-y panel">
      {columns.map((column, index) => {
        const current = mapping[index] ?? null
        const field = FIELDS.find((entry) => entry.key === current)

        return (
          <li key={`${column}-${index}`} className="flex flex-wrap items-center gap-3 p-3">
            <span className="flex min-w-0 flex-1 basis-48 flex-col">
              <span className="truncate font-medium">{column || `Column ${index + 1}`}</span>
              <span className="truncate text-xs text-muted-foreground">
                {sample[index]?.trim() || "— empty in the first row"}
              </span>
            </span>

            <span className="flex flex-col items-end gap-1">
              <NativeSelect
                size="sm"
                aria-label={`${column || `Column ${index + 1}`} maps to`}
                value={current ?? UNMAPPED}
                onChange={(event) =>
                  onChange(index, event.target.value === UNMAPPED ? null : (event.target.value as FieldKey))
                }
                className="w-44"
              >
                <NativeSelectOption value={UNMAPPED}>Do not import</NativeSelectOption>
                {FIELDS.filter((entry) => entry.key === current || !taken.has(entry.key)).map(
                  (entry) => (
                    <NativeSelectOption key={entry.key} value={entry.key}>
                      {entry.label}
                      {entry.required ? " (required)" : ""}
                    </NativeSelectOption>
                  )
                )}
              </NativeSelect>
              <span className="text-xs text-muted-foreground">
                {field ? field.hint : "This column will be left out"}
              </span>
            </span>
          </li>
        )
      })}
    </ul>
  )
}
app/import/components/import-preview.tsx
"use client"

import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"

import { FIELDS, type FieldKey } from "../fields"

/** One previewed row: the line it came from, and a value per target field. */
export type PreviewRow = { line: number; values: Record<string, string> }

const COLUMNS: SimpleTableColumn<PreviewRow>[] = FIELDS.map((field) => ({
  key: field.key,
  header: field.label,
  cell: (row) => {
    const value = row.values[field.key as FieldKey] ?? ""
    // A blank cell says nothing about whether the column was mapped, so an
    // unfilled field is drawn as one rather than as empty space.
    return value.trim() === "" ? <span className="text-muted-foreground">—</span> : value
  },
}))

/** The first rows as they will be written, under the names they will be written to. */
export function ImportPreview({ rows, total }: { rows: PreviewRow[]; total: number }) {
  return (
    <SimpleTable
      columns={COLUMNS}
      rows={rows}
      rowKey={(row) => String(row.line)}
      size="sm"
      caption={`The first ${rows.length} of ${total} rows, as the mapping reads them.`}
      emptyMessage="Nothing in this file mapped to a field."
    />
  )
}
app/import/components/import-wizard.tsx
"use client"

import * as React from "react"

import { AsyncButton } from "@/components/ui/async-button"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { FileDropzone } from "@/components/ui/file-dropzone"
import { Stepper } from "@/components/ui/stepper"
import { Widget } from "@/components/ui/widget"

import { importCustomers, type ImportSummary } from "../actions"
import { parseCsv, type CsvTable } from "../csv"
import { FIELDS, PREVIEW_ROWS, SAMPLE_CSV, initialMapping, type FieldKey } from "../fields"
import { ColumnMapping, type Mapping } from "./column-mapping"
import { ImportPreview, type PreviewRow } from "./import-preview"

const STEPS = [
  { id: "file", title: "Choose a file", description: "A CSV with a header row" },
  { id: "map", title: "Map columns", description: "Each column to a field" },
  { id: "preview", title: "Preview", description: "The first rows, as read" },
  { id: "import", title: "Import", description: "What landed, and what did not" },
]

/** Every row as the mapping reads it: the file's own line, and a value per field. */
function mapRows(table: CsvTable, mapping: Mapping): PreviewRow[] {
  const pairs = Object.entries(mapping)
    .filter(([, field]) => field !== null)
    .map(([index, field]) => [Number(index), field as FieldKey] as const)

  return table.rows.map((row, index) => ({
    // The header is line 1, so the first data row is line 2 — which is the
    // line number a spreadsheet shows the reader.
    line: index + 2,
    values: Object.fromEntries(pairs.map(([column, field]) => [field, row[column] ?? ""])),
  }))
}

/** The required fields nothing has been mapped to yet. */
function missing(mapping: Mapping): string[] {
  const taken = new Set(Object.values(mapping).filter(Boolean))
  return FIELDS.filter((field) => field.required && !taken.has(field.key)).map(
    (field) => field.label
  )
}

/**
 * Drop a CSV, say what its columns are, look at what that produced, and write
 * it — four steps, all in the browser until the last one.
 *
 * The file is parsed here rather than uploaded: a reader deciding what a column
 * means should not have to wait for a round trip between each keystroke, and
 * nothing leaves the page until they press Import. That last step is a server
 * action, because writing rows is the one thing the browser must not do itself.
 */
export function ImportWizard() {
  const [step, setStep] = React.useState(0)
  const [fileName, setFileName] = React.useState<string | null>(null)
  const [table, setTable] = React.useState<CsvTable | null>(null)
  const [mapping, setMapping] = React.useState<Mapping>({})
  const [summary, setSummary] = React.useState<ImportSummary | null>(null)
  const [error, setError] = React.useState<string | null>(null)

  function load(name: string, text: string) {
    const parsed = parseCsv(text)
    if (parsed.columns.length === 0) {
      setError(`${name} has no header row, so there is nothing to map.`)
      return
    }
    setError(null)
    setFileName(name)
    setTable(parsed)
    setMapping(initialMapping(parsed.columns))
    setStep(1)
  }

  async function accept(files: File[]) {
    const file = files[0]
    if (!file) return
    load(file.name, await file.text())
  }

  function restart() {
    setStep(0)
    setFileName(null)
    setTable(null)
    setMapping({})
    setSummary(null)
    setError(null)
  }

  const rows = table ? mapRows(table, mapping) : []
  const unfilled = missing(mapping)

  return (
    <Widget
      title="Import"
      description="Bring a customer list in from a spreadsheet"
      contentClassName="flex flex-col gap-5"
      footer="Nothing leaves the browser until you press Import; the file itself is never uploaded."
    >
      <Stepper steps={STEPS} activeStep={step} size="sm" />

      {step === 0 ? (
        <div className="flex flex-col gap-3">
          <FileDropzone
            accept={[".csv", "text/csv"]}
            multiple={false}
            maxFiles={1}
            aria-label="CSV file"
            description="One CSV with a header row. It is read here in the browser, not uploaded."
            onFilesAccepted={accept}
          />
          {error ? (
            <Callout variant="danger" role="alert">
              {error}
            </Callout>
          ) : null}
          <div className="flex items-center gap-2 text-sm text-muted-foreground">
            <span>No file to hand?</span>
            <Button
              variant="outline"
              size="sm"
              onClick={() => load("sample-customers.csv", SAMPLE_CSV)}
            >
              Use the sample file
            </Button>
          </div>
        </div>
      ) : null}

      {step === 1 && table ? (
        <div className="flex flex-col gap-3">
          <p className="text-sm text-muted-foreground">
            {fileName} · {table.columns.length} columns · {table.rows.length} rows
          </p>
          <ColumnMapping
            columns={table.columns}
            sample={table.rows[0] ?? []}
            mapping={mapping}
            onChange={(index, field) => setMapping((current) => ({ ...current, [index]: field }))}
          />
          <p role="status" className="text-sm text-muted-foreground">
            {unfilled.length === 0
              ? "Every required field has a column."
              : `Still to map: ${unfilled.join(", ")}.`}
          </p>
          <div className="flex gap-2">
            <Button variant="outline" onClick={restart}>
              Choose another file
            </Button>
            <Button disabled={unfilled.length > 0} onClick={() => setStep(2)}>
              Preview
            </Button>
          </div>
        </div>
      ) : null}

      {step === 2 && table ? (
        <div className="flex flex-col gap-3">
          <ImportPreview rows={rows.slice(0, PREVIEW_ROWS)} total={rows.length} />
          {error ? (
            <Callout variant="danger" role="alert">
              {error}
            </Callout>
          ) : null}
          <div className="flex gap-2">
            <Button variant="outline" onClick={() => setStep(1)}>
              Back to mapping
            </Button>
            <AsyncButton
              onClick={async () => {
                const result = await importCustomers(rows)
                if (!result.ok) {
                  setError(result.error.message)
                  return
                }
                setError(null)
                setSummary(result.data)
                setStep(3)
              }}
            >
              {`Import ${rows.length} rows`}
            </AsyncButton>
          </div>
        </div>
      ) : null}

      {step === 3 && summary ? (
        <div className="flex flex-col gap-3">
          <Callout
            variant={summary.skipped === 0 ? "success" : "warning"}
            role="status"
            title={`${summary.imported} rows imported, ${summary.skipped} skipped`}
          >
            {summary.skipped === 0
              ? "Every row in the file was written."
              : "The rows below were left alone; the rest were written."}
          </Callout>

          {summary.refusals.length > 0 ? (
            <ul
              aria-label="Rows that were not imported"
              className="flex flex-col gap-1 panel p-3 text-sm"
            >
              {summary.refusals.map((refusal) => (
                <li key={refusal.line} className="flex gap-2">
                  <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
                    Line {refusal.line}
                  </span>
                  <span>{refusal.message}</span>
                </li>
              ))}
            </ul>
          ) : null}

          <div>
            <Button variant="outline" onClick={restart}>
              Import another file
            </Button>
          </div>
        </div>
      ) : null}
    </Widget>
  )
}