Skip to content
UI Kit
Accent color

Import wizard

Import screens. Upload with a dropzone, matching columns to fields, a check of every row with fixes in place, the import with live progress and pasting from a spreadsheet.

One of 3 components in Import, export and history

5 variants

Variants

Upload

A dropzone that reacts to dragging, reading with progress, a check of the file type, a template to download and recent imports.

Open in Studio

Column matching

Each column with example values and the field it fills, matched on its own where the name fits, used fields blocked and missing required fields named.

Open in Studio

Row check

Every problem counted, cells marked with the reason, fixed in place with a click, and an import button that says how many rows go in.

Open in Studio

Import progress

The rows counted up live with time left, created, updated and skipped as they grow, pause and resume, and the result with an error report.

Open in Studio

Paste from a spreadsheet

Paste cells, the separator is found on its own, the first line can be the header, and a preview shows rows and columns right away.

Open in Studio

Details

What Import wizard brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.1 · since 1.1.0
File
components/ui/import-wizard.tsx
Brings along
utilskitbuttoniconsidebar
npm packages
None
Languages
English and German, 68 texts of its own

Props

The ImportWizardProps interface from components/ui/import-wizard.tsx, with the comments from the code.

  • variantoptional
    ImportWizardVariant

    uploadcolumn-mappingreviewprogresspaste

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlerequired
    string
  • subtitleoptional
    string
  • entityoptional
    string

    What gets imported, e.g. contacts

  • fieldsoptional
    ImportField[]

    Fields of the target

  • columnsoptional
    ImportColumn[]

    column-mapping: the columns found in the file

  • rowsoptional
    Record<string, string>[]

    review: rows as field id to value

  • historyoptional
    PastImport[]

    upload

  • fileNameoptional
    string

    File name on screen

  • defaultTextoptional
    string

    paste: text that starts in the field

  • totaloptional
    number

    progress: how many rows

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on every step. Without it the block simulates the work.

Built-in texts

Import wizard writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 68 texts
stepsEnglish Import stepsGerman Schritte des Imports
stepUploadEnglish UploadGerman Hochladen
stepMapEnglish Match columnsGerman Spalten zuordnen
stepReviewEnglish CheckGerman Prüfen
stepImportEnglish ImportGerman Importieren
dropEnglish Drop a CSV or Excel file hereGerman CSV- oder Excel-Datei hier ablegen
dropActiveEnglish Let go to uploadGerman Loslassen zum Hochladen
orEnglish orGerman oder
browseEnglish Choose a fileGerman Datei auswählen
formatsEnglish CSV, XLSX or TSV, up to 20 MB and 50,000 rows.German CSV, XLSX oder TSV, bis 20 MB und 50.000 Zeilen.
templateEnglish Download a templateGerman Vorlage herunterladen
tryExampleEnglish Try it with {file}German Mit {file} ausprobieren
templateTextEnglish A file with the right columns and two example rows.German Eine Datei mit den richtigen Spalten und zwei Beispielzeilen.
readingEnglish Reading {file}…German {file} wird gelesen…
rowsFoundEnglish {n} rows and {c} columns found in {file}.German {n} Zeilen und {c} Spalten in {file} gefunden.
nextEnglish ContinueGerman Weiter
backEnglish BackGerman Zurück
wrongTypeEnglish This file type cannot be read. Use CSV, XLSX or TSV.German Dieser Dateityp lässt sich nicht lesen. Nutze CSV, XLSX oder TSV.
recentEnglish Recent importsGerman Letzte Importe
rowsNEnglish {n} rowsGerman {n} Zeilen
errorsNEnglish {n} skippedGerman {n} übersprungen
byEnglish by {name}German von {name}
columnEnglish Column in your fileGerman Spalte in deiner Datei
fieldEnglish FieldGerman Feld
sampleEnglish ExampleGerman Beispiel
skipEnglish Do not importGerman Nicht importieren
autoEnglish MatchedGerman Erkannt
requiredEnglish RequiredGerman Pflicht
missingEnglish Still to match {fields}German Noch offen {fields}
allSetEnglish Every required field has a column.German Jedes Pflichtfeld hat eine Spalte.
mappedEnglish {n} of {total} columns matchedGerman {n} von {total} Spalten zugeordnet
usedByEnglish Already used by {col}German Schon belegt durch {col}
mapForEnglish Field for {col}German Feld für {col}
errorsOnlyEnglish Only rows with problemsGerman Nur Zeilen mit Problemen
problemsEnglish {n} problems in {r} rowsGerman {n} Probleme in {r} Zeilen
noProblemsEnglish No problems. Every row can be imported.German Keine Probleme. Jede Zeile kann importiert werden.
fixHintEnglish Click a marked cell to fix it.German Klicke auf eine markierte Zelle, um sie zu korrigieren.
rowEnglish Row {n}German Zeile {n}
editEnglish Edit {field} in row {n}German {field} in Zeile {n} bearbeiten
skipRowsEnglish Import {n} rows, skip {m}German {n} Zeilen importieren, {m} überspringen
importAllEnglish Import {n} rowsGerman {n} Zeilen importieren
importingEnglish Importing {entity}German {entity} werden importiert
processedEnglish {n} of {total} rowsGerman {n} von {total} Zeilen
leftEnglish About {s} seconds leftGerman Noch etwa {s} Sekunden
pauseEnglish PauseGerman Anhalten
resumeEnglish ResumeGerman Fortsetzen
pausedEnglish PausedGerman Angehalten
doneEnglish Import finishedGerman Import abgeschlossen
createdEnglish CreatedGerman Neu angelegt
updatedEnglish UpdatedGerman Aktualisiert
skippedEnglish SkippedGerman Übersprungen
reportEnglish Download error reportGerman Fehlerbericht herunterladen
viewAllEnglish Show imported {entity}German Importierte {entity} ansehen
againEnglish Import another fileGerman Weitere Datei importieren
pasteEnglish Paste from a spreadsheetGerman Aus einer Tabelle einfügen
pasteTextEnglish Copy cells in your spreadsheet and paste them here. The first line is the header.German Kopiere Zellen in deiner Tabelle und füge sie hier ein. Die erste Zeile ist die Kopfzeile.
pasteLabelEnglish DataGerman Daten
pastePlaceholderEnglish Name Email Company Mia Brandt mia@…German Name E-Mail Firma Mia Brandt mia@…
pasteEmptyEnglish Paste to see a preview.German Einfügen, um eine Vorschau zu sehen.
detectedEnglish {rows} rows, {cols} columns, separated by {sep}German {rows} Zeilen, {cols} Spalten, getrennt durch {sep}
tabEnglish tabsGerman Tabulatoren
commaEnglish commasGerman Kommas
semicolonEnglish semicolonsGerman Semikolons
previewEnglish PreviewGerman Vorschau
firstHeaderEnglish First line is the headerGerman Erste Zeile ist die Kopfzeile
moreRowsEnglish {n} more rowsGerman {n} weitere Zeilen
useDataEnglish Use these {n} rowsGerman Diese {n} Zeilen verwenden
clearEnglish ClearGerman Leeren

Accessibility

Read from the source of the component.

  • Import wizard handles the keys Escape, Enter and Space itself.
  • Sets aria-current, aria-label, aria-live, aria-valuemax, aria-valuemin and aria-valuenow, so screen readers announce purpose and state.
  • Uses the roles progressbar and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,037 lines
components/ui/import-wizard.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
import { useScrollFade } from '@/components/ui/motion';

export type ImportWizardVariant = 'upload' | 'column-mapping' | 'review' | 'progress' | 'paste';

export interface ImportField {
  id: string;
  label: string;
  required?: boolean;
  /** What a valid value looks like, e.g. email, number, date */
  type?: 'text' | 'email' | 'number' | 'date';
  /** Column names that map to this field on their own */
  aliases?: string[];
}

export interface ImportColumn {
  /** Header in the file */
  name: string;
  /** First values for a preview */
  samples: string[];
}

export interface PastImport {
  id: string;
  file: string;
  /** ISO date */
  date: string;
  rows: number;
  errors?: number;
  by?: string;
}

export interface ImportWizardProps {
  variant?: ImportWizardVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title: string;
  subtitle?: string;
  /** What gets imported, e.g. contacts */
  entity?: string;
  /** Fields of the target */
  fields?: ImportField[];
  /** column-mapping: the columns found in the file */
  columns?: ImportColumn[];
  /** review: rows as field id to value */
  rows?: Record<string, string>[];
  /** upload */
  history?: PastImport[];
  /** File name on screen */
  fileName?: string;
  /** paste: text that starts in the field */
  defaultText?: string;
  /** progress: how many rows */
  total?: number;
  /** Called on every step. Without it the block simulates the work. */
  onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    steps: 'Import steps',
    stepUpload: 'Upload',
    stepMap: 'Match columns',
    stepReview: 'Check',
    stepImport: 'Import',
    drop: 'Drop a CSV or Excel file here',
    dropActive: 'Let go to upload',
    or: 'or',
    browse: 'Choose a file',
    formats: 'CSV, XLSX or TSV, up to 20 MB and 50,000 rows.',
    template: 'Download a template',
    tryExample: 'Try it with {file}',
    templateText: 'A file with the right columns and two example rows.',
    reading: 'Reading {file}…',
    rowsFound: '{n} rows and {c} columns found in {file}.',
    next: 'Continue',
    back: 'Back',
    wrongType: 'This file type cannot be read. Use CSV, XLSX or TSV.',
    recent: 'Recent imports',
    rowsN: '{n} rows',
    errorsN: '{n} skipped',
    by: 'by {name}',
    column: 'Column in your file',
    field: 'Field',
    sample: 'Example',
    skip: 'Do not import',
    auto: 'Matched',
    required: 'Required',
    missing: 'Still to match {fields}',
    allSet: 'Every required field has a column.',
    mapped: '{n} of {total} columns matched',
    usedBy: 'Already used by {col}',
    mapFor: 'Field for {col}',
    errorsOnly: 'Only rows with problems',
    problems: '{n} problems in {r} rows',
    noProblems: 'No problems. Every row can be imported.',
    fixHint: 'Click a marked cell to fix it.',
    row: 'Row {n}',
    edit: 'Edit {field} in row {n}',
    skipRows: 'Import {n} rows, skip {m}',
    importAll: 'Import {n} rows',
    importing: 'Importing {entity}',
    processed: '{n} of {total} rows',
    left: 'About {s} seconds left',
    pause: 'Pause',
    resume: 'Resume',
    paused: 'Paused',
    done: 'Import finished',
    created: 'Created',
    updated: 'Updated',
    skipped: 'Skipped',
    report: 'Download error report',
    viewAll: 'Show imported {entity}',
    again: 'Import another file',
    paste: 'Paste from a spreadsheet',
    pasteText: 'Copy cells in your spreadsheet and paste them here. The first line is the header.',
    pasteLabel: 'Data',
    pastePlaceholder: 'Name\tEmail\tCompany\nMia Brandt\tmia@…',
    pasteEmpty: 'Paste to see a preview.',
    detected: '{rows} rows, {cols} columns, separated by {sep}',
    tab: 'tabs',
    comma: 'commas',
    semicolon: 'semicolons',
    preview: 'Preview',
    firstHeader: 'First line is the header',
    moreRows: '{n} more rows',
    useData: 'Use these {n} rows',
    clear: 'Clear',
  },
  de: {
    steps: 'Schritte des Imports',
    stepUpload: 'Hochladen',
    stepMap: 'Spalten zuordnen',
    stepReview: 'Prüfen',
    stepImport: 'Importieren',
    drop: 'CSV- oder Excel-Datei hier ablegen',
    dropActive: 'Loslassen zum Hochladen',
    or: 'oder',
    browse: 'Datei auswählen',
    formats: 'CSV, XLSX oder TSV, bis 20 MB und 50.000 Zeilen.',
    template: 'Vorlage herunterladen',
    tryExample: 'Mit {file} ausprobieren',
    templateText: 'Eine Datei mit den richtigen Spalten und zwei Beispielzeilen.',
    reading: '{file} wird gelesen…',
    rowsFound: '{n} Zeilen und {c} Spalten in {file} gefunden.',
    next: 'Weiter',
    back: 'Zurück',
    wrongType: 'Dieser Dateityp lässt sich nicht lesen. Nutze CSV, XLSX oder TSV.',
    recent: 'Letzte Importe',
    rowsN: '{n} Zeilen',
    errorsN: '{n} übersprungen',
    by: 'von {name}',
    column: 'Spalte in deiner Datei',
    field: 'Feld',
    sample: 'Beispiel',
    skip: 'Nicht importieren',
    auto: 'Erkannt',
    required: 'Pflicht',
    missing: 'Noch offen {fields}',
    allSet: 'Jedes Pflichtfeld hat eine Spalte.',
    mapped: '{n} von {total} Spalten zugeordnet',
    usedBy: 'Schon belegt durch {col}',
    mapFor: 'Feld für {col}',
    errorsOnly: 'Nur Zeilen mit Problemen',
    problems: '{n} Probleme in {r} Zeilen',
    noProblems: 'Keine Probleme. Jede Zeile kann importiert werden.',
    fixHint: 'Klicke auf eine markierte Zelle, um sie zu korrigieren.',
    row: 'Zeile {n}',
    edit: '{field} in Zeile {n} bearbeiten',
    skipRows: '{n} Zeilen importieren, {m} überspringen',
    importAll: '{n} Zeilen importieren',
    importing: '{entity} werden importiert',
    processed: '{n} von {total} Zeilen',
    left: 'Noch etwa {s} Sekunden',
    pause: 'Anhalten',
    resume: 'Fortsetzen',
    paused: 'Angehalten',
    done: 'Import abgeschlossen',
    created: 'Neu angelegt',
    updated: 'Aktualisiert',
    skipped: 'Übersprungen',
    report: 'Fehlerbericht herunterladen',
    viewAll: 'Importierte {entity} ansehen',
    again: 'Weitere Datei importieren',
    paste: 'Aus einer Tabelle einfügen',
    pasteText: 'Kopiere Zellen in deiner Tabelle und füge sie hier ein. Die erste Zeile ist die Kopfzeile.',
    pasteLabel: 'Daten',
    pastePlaceholder: 'Name\tE-Mail\tFirma\nMia Brandt\tmia@…',
    pasteEmpty: 'Einfügen, um eine Vorschau zu sehen.',
    detected: '{rows} Zeilen, {cols} Spalten, getrennt durch {sep}',
    tab: 'Tabulatoren',
    comma: 'Kommas',
    semicolon: 'Semikolons',
    preview: 'Vorschau',
    firstHeader: 'Erste Zeile ist die Kopfzeile',
    moreRows: '{n} weitere Zeilen',
    useData: 'Diese {n} Zeilen verwenden',
    clear: 'Leeren',
  },
};

type Texts = (typeof TEXTS)['en'];

const INVALID = {
  en: { email: 'Not an email address', number: 'Not a number', date: 'Use the format YYYY-MM-DD', required: 'Required' },
  de: { email: 'Keine E-Mail-Adresse', number: 'Keine Zahl', date: 'Format JJJJ-MM-TT verwenden', required: 'Pflichtfeld' },
};

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion of the app screens                                           */
/* ------------------------------------------------------------------ */
/*
 * The same small set in every app block (auth, dashboard, files, inbox …),
 * kept in the block so it ships on its own:
 *   useEnter   entrance that waits until its area scrolls in. The area gets
 *              data-kitm-scope and data-kitm={state}, children take kitm-wipe
 *              (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
 *              kitm-up and kitm-pop, with --kitm-d as delay.
 *   useGlide   a marker inside the active item ([data-glide]) glides over
 *              from where the previous one stood: tabs, segments, steps.
 *   kitm-in, kitm-fly, kitm-tick for things that appear after an action.
 * The server renders the end state, without JavaScript nothing waits longer
 * than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
 * clip-path change, so no layout shifts.
 */
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
  ['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
  ['grow-x', 'transform:scaleX(0)', 900],
  ['grow-y', 'transform:scaleY(0)', 800],
  ['up', 'opacity:0;transform:translateY(10px)', 600],
  ['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
  KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
  '@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
  '.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
  '@media (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
  '[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
  '}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
  '}';

function KitmStyle() {
  // React hoists it once into the head, however many app blocks render
  return (
    <style href="kitm" precedence="default">
      {KITM_CSS}
    </style>
  );
}

/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setState('play');
        io.disconnect();
      },
      { rootMargin: '0px 0px -8% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}

type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
  const r = el.getBoundingClientRect();
  const b = root.getBoundingClientRect();
  return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};

/**
 * The marker ([data-glide]) is rendered inside the active item, so the server
 * and a page without JavaScript show it in place. After a change it starts
 * where the old one stood and glides into the new item.
 */
function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Box | null>(null);
  // Read during render, while the DOM still shows the old marker
  if (typeof window !== 'undefined' && ref.current) {
    const m = ref.current.querySelector('[data-glide]');
    before.current = m ? boxOf(m, ref.current) : null;
  }
  useIso(() => {
    const root = ref.current;
    const m = root?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!root || !m || !a || prefersReducedMotion()) return;
    const b = boxOf(m, root);
    if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

type P = Omit<ImportWizardProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; locale: Locale; entity: string };

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
const num = (locale: Locale, n: number) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(n);
const dateFmt = (locale: Locale, iso: string) => new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }).format(new Date(iso + 'T12:00:00Z'));

function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
      </div>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </div>
  );
}

function Main({ children, className }: { children: React.ReactNode; className?: string }) {
  return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

/** The four steps of an import, the current one marked. A line on desktop, a counter on phones. */
function Steps({ t, at }: { t: Texts; at: number }) {
  const steps = [t.stepUpload, t.stepMap, t.stepReview, t.stepImport];
  return (
    <nav aria-label={t.steps} className="mt-5">
      <ol className="hidden items-center gap-3 sm:flex">
        {steps.map((s, i) => (
          <li key={s} aria-current={i === at ? 'step' : undefined} className="flex flex-1 items-center gap-3">
            <span className={cn('grid size-7 shrink-0 place-items-center rounded-full text-[12px] font-semibold tabular-nums transition-colors', i < at ? 'bg-primary text-primary-foreground' : i === at ? 'bg-[color-mix(in_oklab,var(--primary)_16%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))] ring-1 ring-[color-mix(in_oklab,var(--primary)_45%,transparent)]' : 'bg-muted text-subtle-foreground')}>{i < at ? <Icon name="check" size={13} strokeWidth={2.4} /> : i + 1}</span>
            <span className={cn('whitespace-nowrap text-[13px] font-medium', i <= at ? 'text-foreground' : 'text-subtle-foreground')}>{s}</span>
            {i < steps.length - 1 && <span aria-hidden className={cn('h-px flex-1', i < at ? 'bg-primary' : 'bg-border')} />}
          </li>
        ))}
      </ol>
      <p className="flex items-center justify-between text-[12.5px] text-muted-foreground sm:hidden">
        <span className="font-medium text-foreground">{steps[at]}</span>
        <span className="tabular-nums">
          {at + 1} / {steps.length}
        </span>
      </p>
      <span aria-hidden className="mt-2 grid grid-cols-4 gap-1.5 sm:hidden">
        {steps.map((s, i) => (
          <span key={s} className={cn('h-1 rounded-full', i <= at ? 'bg-primary' : 'bg-track')} />
        ))}
      </span>
    </nav>
  );
}

function Footer({ children }: { children: React.ReactNode }) {
  return <div className="flex flex-col-reverse gap-2.5 border-t border-border bg-surface px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">{children}</div>;
}

/** Spreadsheet file icon drawn for the kit, with the extension on the fold */
function FileArt({ ext, className }: { ext: string; className?: string }) {
  return (
    <span aria-hidden className={cn('relative grid h-12 w-10 shrink-0 place-items-end rounded-md border border-border bg-card shadow-[var(--shadow-sm)]', className)}>
      <span className="absolute end-0 top-0 size-3 rounded-bl-[4px] border-b border-s border-border bg-surface-2" />
      <span className="mb-1.5 me-auto ms-1 rounded-[3px] bg-success px-1 text-[9px] font-bold uppercase leading-4 text-white">{ext}</span>
    </span>
  );
}

const isValid = (f: ImportField, v: string) => {
  const s = (v ?? '').trim();
  if (!s) return !f.required;
  if (f.type === 'email') return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(s);
  if (f.type === 'number') return /^-?\d+([.,]\d+)?$/.test(s);
  if (f.type === 'date') return /^\d{4}-\d{2}-\d{2}$/.test(s);
  return true;
};
const why = (f: ImportField, v: string, locale: Locale) => (!(v ?? '').trim() ? INVALID[locale].required : INVALID[locale][f.type as 'email'] ?? '');

/* ------------------------------------------------------------------ */
/* upload                                                              */
/* ------------------------------------------------------------------ */

function Upload({ t, title, subtitle, history = [], fileName = 'contacts.csv', locale, onAction }: P) {
  const id = useId();
  const [over, setOver] = useState(false);
  const [file, setFile] = useState<{ name: string; state: 'reading' | 'ready' | 'error'; progress: number } | null>(null);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const inputRef = useRef<HTMLInputElement>(null);
  const take = async (name: string) => {
    if (!/\.(csv|xlsx|tsv)$/i.test(name)) return setFile({ name, state: 'error', progress: 0 });
    setFile({ name, state: 'reading', progress: 0 });
    const steps = prefersReducedMotion() ? 1 : 12;
    for (let i = 1; i <= steps; i++) {
      await wait(70);
      setFile({ name, state: 'reading', progress: i / steps });
    }
    if (onAction) await onAction('upload', { file: name });
    setFile({ name, state: 'ready', progress: 1 });
  };
  const ext = (n: string) => n.split('.').pop() ?? '';
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <Steps t={t} at={0} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
        <section className="kitm-up min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <div className="p-4 sm:p-6">
            <label
              htmlFor={`${id}-f`}
              onDragOver={(e) => {
                e.preventDefault();
                setOver(true);
              }}
              onDragLeave={() => setOver(false)}
              onDrop={(e) => {
                e.preventDefault();
                setOver(false);
                const f = e.dataTransfer.files[0];
                if (f) void take(f.name);
              }}
              className={cn(
                'relative flex min-h-56 cursor-pointer flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border-2 border-dashed px-6 py-10 text-center transition-[border-color,background-color] duration-300 sm:min-h-64',
                over ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_7%,transparent)]' : 'border-border-strong hover:border-[color-mix(in_oklab,var(--primary)_50%,var(--border-strong))] hover:bg-[color-mix(in_oklab,var(--foreground)_2%,transparent)]',
              )}
            >
              <span className={cn('grid size-14 place-items-center rounded-2xl bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))] transition-transform duration-500 ease-[cubic-bezier(0.34,1.4,0.64,1)]', over && '-translate-y-1.5 scale-110')}>
                <Icon name="cloud-upload" size={26} />
              </span>
              <span className="text-[16px] font-semibold text-foreground">{over ? t.dropActive : t.drop}</span>
              <span className="text-[13px] text-muted-foreground">
                {t.or} <span className="font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] underline-offset-4 hover:underline">{t.browse}</span>
              </span>
              <span className="text-[12.5px] text-subtle-foreground">{t.formats}</span>
              <input
                ref={inputRef}
                id={`${id}-f`}
                type="file"
                accept=".csv,.xlsx,.tsv"
                className="sr-only"
                onChange={(e) => {
                  const f = e.target.files?.[0];
                  if (f) void take(f.name);
                }}
              />
            </label>
            {file && (
              <div key={file.name} className={cn('kitm-in mt-4 flex items-center gap-3.5 rounded-xl border p-3.5', file.state === 'error' ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_6%,var(--card))]' : 'border-border bg-surface')}>
                <FileArt ext={ext(file.name)} />
                <div className="min-w-0 flex-1" role="status">
                  <p className="truncate text-[14px] font-medium text-foreground">{file.name}</p>
                  {file.state === 'error' ? (
                    <p className="text-[12.5px] font-medium text-destructive">{t.wrongType}</p>
                  ) : file.state === 'reading' ? (
                    <>
                      <p className="text-[12.5px] text-muted-foreground">{fill(t.reading, { file: file.name })}</p>
                      <span className="mt-1.5 block h-1.5 overflow-hidden rounded-full bg-track">
                        <span className="block h-full origin-left rounded-full bg-primary transition-transform duration-150" style={{ transform: `scaleX(${round(file.progress, 3)})` }} />
                      </span>
                    </>
                  ) : (
                    <p className="kitm-in flex items-center gap-1.5 text-[12.5px] text-success-foreground">
                      <Icon name="check-circle" size={14} />
                      {fill(t.rowsFound, { n: num(locale, 1284), c: 7, file: file.name })}
                    </p>
                  )}
                </div>
              </div>
            )}
          </div>
          <Footer>
            {/* The file name stays whole, in a narrow column the line wraps instead of cutting it */}
            <button type="button" onClick={() => void take(fileName)} className="inline-flex min-h-11 min-w-0 items-center gap-2 rounded-md px-1 py-1 text-left text-[13px] font-medium leading-snug text-muted-foreground hover:text-foreground sm:min-h-9">
              <Icon name="file-plus" size={16} className="shrink-0" />
              <span className="min-w-0 text-pretty break-words">{fill(t.tryExample, { file: fileName })}</span>
            </button>
            <Button size="sm" icon="arrow-right" disabled={file?.state !== 'ready'} onClick={() => void onAction?.('next', { file: file?.name })}>
              {t.next}
            </Button>
          </Footer>
        </section>
        <div className="grid gap-6">
          <a href="#" onClick={(e) => e.preventDefault()} className="kitm-up [--kitm-d:100ms] group flex items-start gap-3.5 rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,translate] duration-300 hover:-translate-y-0.5 hover:border-border-strong sm:p-5">
            <span className="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-foreground transition-transform duration-300 group-hover:translate-y-0.5">
              <Icon name="download" size={18} />
            </span>
            <span className="min-w-0">
              <span className="block text-[14px] font-semibold text-foreground">{t.template}</span>
              <span className="mt-0.5 block text-pretty text-[13px] leading-snug text-muted-foreground">{t.templateText}</span>
            </span>
          </a>
          {history.length > 0 && (
            <section className="kitm-up [--kitm-d:160ms] overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
              <h2 className="border-b border-border px-4 py-3.5 text-[14px] font-semibold text-foreground sm:px-5">{t.recent}</h2>
              <ul className="divide-y divide-border">
                {history.map((h) => (
                  <li key={h.id} className="flex items-center gap-3 px-4 py-3 sm:px-5">
                    <FileArt ext={ext(h.file)} className="h-9 w-7 [&>span:last-child]:hidden" />
                    <span className="min-w-0 flex-1">
                      <span className="block truncate text-[13.5px] font-medium text-foreground">{h.file}</span>
                      <span className="block truncate text-[12px] text-muted-foreground">
                        {dateFmt(locale, h.date)}
                        {h.by && ` · ${fill(t.by, { name: h.by })}`}
                      </span>
                    </span>
                    <span className="shrink-0 text-right text-[12px] tabular-nums">
                      <span className="block text-foreground">{fill(t.rowsN, { n: num(locale, h.rows) })}</span>
                      {h.errors ? <span className="block text-warning-foreground">{fill(t.errorsN, { n: h.errors })}</span> : null}
                    </span>
                  </li>
                ))}
              </ul>
            </section>
          )}
        </div>
      </div>
      <Live text={file?.state === 'ready' ? fill(t.rowsFound, { n: num(locale, 1284), c: 7, file: file.name }) : ''} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* column-mapping                                                      */
/* ------------------------------------------------------------------ */

const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9äöüß]/g, '');

function Mapping({ t, title, subtitle, fields = [], columns = [], fileName, locale, onAction }: P) {
  const id = useId();
  const autoMap = useMemo(() => {
    const m: Record<string, string> = {};
    for (const c of columns) {
      const hit = fields.find((f) => norm(f.label) === norm(c.name) || norm(f.id) === norm(c.name) || (f.aliases ?? []).some((a) => norm(a) === norm(c.name)));
      if (hit && !Object.values(m).includes(hit.id)) m[c.name] = hit.id;
    }
    return m;
  }, [columns, fields]);
  const [map, setMap] = useState<Record<string, string>>(autoMap);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const usedBy = (fid: string, except: string) => Object.entries(map).find(([c, f]) => f === fid && c !== except)?.[0];
  const missing = fields.filter((f) => f.required && !Object.values(map).includes(f.id));
  const matched = Object.values(map).filter(Boolean).length;
  return (
    <Main>
      <Header title={title} subtitle={subtitle ?? fileName} />
      <Steps t={t} at={1} />
      <div ref={scope} {...enter} className="mt-6">
        <section className="kitm-up min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <div className="flex flex-col gap-2 border-b border-border px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-6">
            <p key={matched} className="kitm-in text-[13px] tabular-nums text-muted-foreground">
              {fill(t.mapped, { n: matched, total: columns.length })}
            </p>
            <p role="status" key={missing.map((m) => m.id).join()} className={cn('kitm-in flex items-center gap-1.5 text-[13px] font-medium', missing.length ? 'text-warning-foreground' : 'text-success-foreground')}>
              <Icon name={missing.length ? 'alert-circle' : 'check-circle'} size={15} />
              {missing.length ? fill(t.missing, { fields: missing.map((m) => m.label).join(', ') }) : t.allSet}
            </p>
          </div>
          {/* Desktop: three columns with an arrow between file and field. Phones: one card per column. */}
          <div className="hidden grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)_2.5rem_minmax(0,1.1fr)] gap-4 border-b border-border bg-surface px-6 py-2.5 text-[12px] font-medium text-muted-foreground md:grid">
            <span>{t.column}</span>
            <span>{t.sample}</span>
            <span />
            <span>{t.field}</span>
          </div>
          <ul className="divide-y divide-border">
            {columns.map((c, i) => {
              const fid = map[c.name] ?? '';
              const f = fields.find((x) => x.id === fid);
              const bad = f ? c.samples.filter((v) => !isValid(f, v)).length : 0;
              return (
                <li key={c.name} className="kitm-up grid gap-3 px-4 py-4 sm:px-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)_2.5rem_minmax(0,1.1fr)] md:items-center md:gap-4" style={{ '--kitm-d': `${i * 45}ms` } as React.CSSProperties}>
                  <p className="flex min-w-0 items-center gap-2 text-[14px] font-medium text-foreground">
                    <Icon name="columns" size={15} className="shrink-0 text-subtle-foreground" />
                    <span className="truncate">{c.name}</span>
                  </p>
                  <p className="flex min-w-0 flex-wrap gap-1.5">
                    {c.samples.slice(0, 3).map((v, j) => (
                      <span key={j} className={cn('max-w-full truncate rounded-md px-1.5 py-0.5 text-[12px] tabular-nums', f && !isValid(f, v) ? 'bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-warning-foreground' : 'bg-muted text-muted-foreground')}>
                        {v || '·'}
                      </span>
                    ))}
                  </p>
                  <span aria-hidden className="hidden justify-center md:flex">
                    <Icon name="arrow-right" size={16} className={cn('transition-colors duration-300', fid ? 'text-primary' : 'text-subtle-foreground')} />
                  </span>
                  <div className="min-w-0">
                    <span className="relative block">
                      <select
                        id={`${id}-${i}`}
                        aria-label={fill(t.mapFor, { col: c.name })}
                        value={fid}
                        onChange={(e) => setMap((m) => ({ ...m, [c.name]: e.target.value }))}
                        className={cn('h-11 w-full cursor-pointer appearance-none truncate rounded-lg border bg-background ps-3 pe-9 text-base shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-10 sm:text-[13.5px]', fid ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] text-foreground' : 'border-input text-muted-foreground')}
                      >
                        <option value="">{t.skip}</option>
                        {fields.map((x) => {
                          const by = usedBy(x.id, c.name);
                          return (
                            <option key={x.id} value={x.id} disabled={Boolean(by)}>
                              {x.label}
                              {x.required ? ` · ${t.required}` : ''}
                              {by ? ` · ${fill(t.usedBy, { col: by })}` : ''}
                            </option>
                          );
                        })}
                      </select>
                      <Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
                    </span>
                    <p className="mt-1.5 flex flex-wrap gap-x-3 text-[12px]">
                      {fid && autoMap[c.name] === fid && (
                        <span className="inline-flex items-center gap-1 text-success-foreground">
                          <Icon name="sparkle" size={12} />
                          {t.auto}
                        </span>
                      )}
                      {bad > 0 && f && <span className="text-warning-foreground">{why(f, c.samples.find((v) => !isValid(f, v)) ?? '', locale)}</span>}
                    </p>
                  </div>
                </li>
              );
            })}
          </ul>
          <Footer>
            <Button variant="ghost" size="sm" iconLeft="arrow-left" onClick={() => void onAction?.('back', {})}>
              {t.back}
            </Button>
            <Button size="sm" icon="arrow-right" disabled={missing.length > 0} onClick={() => void onAction?.('map', { map })}>
              {t.next}
            </Button>
          </Footer>
        </section>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* review                                                              */
/* ------------------------------------------------------------------ */

function Review({ t, title, subtitle, fields = [], rows = [], locale, onAction }: P) {
  const [data, setData] = useState(rows);
  const [only, setOnly] = useState(true);
  const [edit, setEdit] = useState<{ r: number; f: string } | null>(null);
  const [draft, setDraft] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const bad = (r: Record<string, string>) => fields.filter((f) => !isValid(f, r[f.id] ?? ''));
  const problemRows = data.map((r, i) => ({ r, i, issues: bad(r) })).filter((x) => x.issues.length);
  const problems = problemRows.reduce((a, x) => a + x.issues.length, 0);
  const shown = (only ? problemRows : data.map((r, i) => ({ r, i, issues: bad(r) }))).slice(0, 40);
  const commit = () => {
    if (!edit) return;
    setData((d) => d.map((row, i) => (i === edit.r ? { ...row, [edit.f]: draft } : row)));
    setEdit(null);
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <Steps t={t} at={2} />
      <div ref={scope} {...enter} className="mt-6">
        <section className="kitm-up min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <div className="flex flex-col gap-3 border-b border-border px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-6">
            <p role="status" key={problems} className={cn('kitm-in flex items-center gap-2 text-[13.5px] font-medium', problems ? 'text-warning-foreground' : 'text-success-foreground')}>
              <Icon name={problems ? 'alert-circle' : 'check-circle'} size={16} />
              {problems ? fill(t.problems, { n: problems, r: problemRows.length }) : t.noProblems}
              {problems > 0 && <span className="hidden font-normal text-muted-foreground md:inline"> · {t.fixHint}</span>}
            </p>
            <label className="flex min-h-11 cursor-pointer items-center gap-2.5 text-[13px] text-foreground sm:min-h-0">
              <input type="checkbox" checked={only} onChange={(e) => setOnly(e.target.checked)} className="size-4 accent-[var(--primary)]" />
              {t.errorsOnly}
            </label>
          </div>
          {/* Desktop and tablet: a grid of cells. Phones: one card per row with only the fields that need work. */}
          <div className="hidden md:block">
            <table className="w-full table-fixed border-collapse text-left text-[13px]">
              <thead>
                <tr className="border-b border-border bg-surface text-[12px] font-medium text-muted-foreground">
                  <th scope="col" className="w-16 px-4 py-2.5 font-medium">
                    #
                  </th>
                  {fields.map((f) => (
                    <th key={f.id} scope="col" className="truncate px-3 py-2.5 font-medium">
                      {f.label}
                      {f.required && <span className="text-destructive"> *</span>}
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody className="divide-y divide-border">
                {shown.map(({ r, i, issues }) => (
                  <tr key={i} className={cn(issues.length ? '' : 'text-muted-foreground')}>
                    <td className="px-4 py-2 tabular-nums text-subtle-foreground">{i + 2}</td>
                    {fields.map((f) => {
                      const wrong = issues.includes(f);
                      const editing = edit?.r === i && edit.f === f.id;
                      return (
                        <td key={f.id} className={cn('relative px-1.5 py-1', wrong && 'bg-[color-mix(in_oklab,var(--warning)_11%,transparent)]')}>
                          {editing ? (
                            <input
                              autoFocus
                              value={draft}
                              aria-label={fill(t.edit, { field: f.label, n: i + 2 })}
                              onChange={(e) => setDraft(e.target.value)}
                              onBlur={commit}
                              onKeyDown={(e) => {
                                if (e.key === 'Enter') commit();
                                if (e.key === 'Escape') setEdit(null);
                              }}
                              className="h-9 w-full rounded-md border border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] bg-background px-2 text-[13px] text-foreground shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] outline-none"
                            />
                          ) : (
                            <button
                              type="button"
                              title={wrong ? why(f, r[f.id] ?? '', locale) : undefined}
                              aria-label={fill(t.edit, { field: f.label, n: i + 2 })}
                              onClick={() => {
                                setDraft(r[f.id] ?? '');
                                setEdit({ r: i, f: f.id });
                              }}
                              className={cn('flex h-9 w-full min-w-0 items-center gap-1.5 rounded-md px-2 text-left transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)]', wrong ? 'text-warning-foreground' : '')}
                            >
                              {wrong && <Icon name="alert-circle" size={13} className="shrink-0" />}
                              <span className="truncate">{r[f.id] || (wrong ? why(f, '', locale) : '')}</span>
                            </button>
                          )}
                        </td>
                      );
                    })}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          <ul className="divide-y divide-border md:hidden">
            {shown.map(({ r, i, issues }) => (
              <li key={i} className="px-4 py-3.5">
                <p className="flex items-center justify-between text-[12.5px] text-muted-foreground">
                  <span className="font-medium text-foreground">{fill(t.row, { n: i + 2 })}</span>
                  <span className="truncate ps-3">{r[fields[0]?.id] ?? ''}</span>
                </p>
                <div className="mt-2 grid gap-2">
                  {(issues.length ? issues : fields.slice(0, 2)).map((f) => (
                    <label key={f.id} className="grid gap-1 text-[12px] text-muted-foreground">
                      <span className="flex justify-between gap-2">
                        {f.label}
                        {issues.includes(f) && <span className="font-medium text-warning-foreground">{why(f, r[f.id] ?? '', locale)}</span>}
                      </span>
                      <input value={r[f.id] ?? ''} onChange={(e) => setData((d) => d.map((row, j) => (j === i ? { ...row, [f.id]: e.target.value } : row)))} className={cn('h-11 rounded-lg border bg-background px-3 text-base text-foreground outline-none focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]', issues.includes(f) ? 'border-[color-mix(in_oklab,var(--warning)_70%,var(--border))]' : 'border-input')} />
                    </label>
                  ))}
                </div>
              </li>
            ))}
          </ul>
          <Footer>
            <Button variant="ghost" size="sm" iconLeft="arrow-left" onClick={() => void onAction?.('back', {})}>
              {t.back}
            </Button>
            <Button size="sm" icon="arrow-right" onClick={() => void onAction?.('import', { rows: data.length - problemRows.length })}>
              {problemRows.length ? fill(t.skipRows, { n: num(locale, data.length - problemRows.length), m: problemRows.length }) : fill(t.importAll, { n: num(locale, data.length) })}
            </Button>
          </Footer>
        </section>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* progress                                                            */
/* ------------------------------------------------------------------ */

function Progress({ t, title, subtitle, total = 1284, entity, locale, onAction }: P) {
  const [n, setN] = useState(0);
  const [paused, setPaused] = useState(false);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const started = enter['data-kitm'] === 'play';
  // Works through the rows while on screen; reduced motion jumps to the end
  useEffect(() => {
    if (!started || paused) return;
    if (prefersReducedMotion()) return setN(total);
    if (n >= total) return;
    const id = window.setTimeout(() => setN((x) => Math.min(total, x + Math.max(7, Math.round(total / 90)))), 60);
    return () => window.clearTimeout(id);
  }, [n, started, paused, total]);
  useEffect(() => {
    if (n >= total) void onAction?.('done', { total });
  }, [n >= total]); // eslint-disable-line react-hooks/exhaustive-deps
  const done = n >= total;
  const skipped = Math.round(total * 0.011);
  const updated = Math.round(total * 0.27);
  const created = total - skipped - updated;
  const share = n / total;
  const secs = Math.max(1, Math.round(((total - n) / Math.max(7, Math.round(total / 90))) * 0.06));
  const stats: { label: string; value: number; tone: string; icon: IconName }[] = [
    { label: t.created, value: created, tone: 'text-success-foreground', icon: 'plus-circle' },
    { label: t.updated, value: updated, tone: 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]', icon: 'refresh' },
    { label: t.skipped, value: skipped, tone: 'text-warning-foreground', icon: 'minus-circle' },
  ];
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <Steps t={t} at={done ? 4 : 3} />
      <div ref={scope} {...enter} className="mt-6">
        <section className="kitm-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <div className="grid gap-6 p-5 sm:p-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-center lg:gap-10">
            <div>
            <div className="flex items-center gap-4">
              <span className={cn('relative grid size-12 shrink-0 place-items-center rounded-full transition-colors duration-500', done ? 'bg-[color-mix(in_oklab,var(--success)_15%,transparent)] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
                <Icon key={String(done)} name={done ? 'check' : paused ? 'pause' : 'upload'} size={22} strokeWidth={done ? 2.4 : 1.8} className={done ? 'kitm-pop' : paused ? undefined : 'motion-safe:animate-pulse'} />
              </span>
              <div className="min-w-0">
                <h2 key={String(done)} className="kitm-in text-[18px] font-semibold tracking-[-0.015em] text-foreground">
                  {done ? t.done : paused ? t.paused : fill(t.importing, { entity })}
                </h2>
                <p className="text-[13px] tabular-nums text-muted-foreground">
                  {fill(t.processed, { n: num(locale, n), total: num(locale, total) })}
                  {!done && !paused && ` · ${fill(t.left, { s: secs })}`}
                </p>
              </div>
            </div>
            <div className="relative mt-6 h-2.5 overflow-hidden rounded-full bg-track" role="progressbar" aria-valuemin={0} aria-valuemax={total} aria-valuenow={n} aria-label={fill(t.importing, { entity })}>
              <span className={cn('absolute inset-y-0 left-0 w-full origin-left rounded-full transition-[transform,background-color] duration-150', done ? 'bg-success' : 'bg-primary')} style={{ transform: `scaleX(${round(share, 4)})` }} />
              {!done && !paused && <span aria-hidden className="absolute inset-y-0 left-0 w-1/3 bg-[linear-gradient(90deg,transparent,rgb(255_255_255/0.35),transparent)] motion-safe:animate-[kitw-sweep_1.4s_linear_infinite]" />}
            </div>
            </div>
            <dl className="grid grid-cols-3 gap-px overflow-hidden rounded-lg border border-border bg-border">
              {stats.map((s) => (
                <div key={s.label} className="bg-surface p-3 sm:p-4">
                  <dt className="flex items-center gap-1.5 text-[12px] text-muted-foreground">
                    <Icon name={s.icon} size={13} className={s.tone} />
                    {s.label}
                  </dt>
                  <dd className="mt-1 text-[18px] font-semibold tabular-nums text-foreground sm:text-[22px]">{num(locale, Math.round(s.value * share))}</dd>
                </div>
              ))}
            </dl>
          </div>
          <Footer>
            {done ? (
              <>
                <Button variant="ghost" size="sm" iconLeft="download" onClick={() => void onAction?.('report', {})}>
                  {t.report}
                </Button>
                <div className="flex flex-col-reverse gap-2.5 sm:flex-row">
                  <Button variant="outline" size="sm" onClick={() => setN(0)}>
                    {t.again}
                  </Button>
                  <Button size="sm" icon="arrow-right" onClick={() => void onAction?.('view', {})}>
                    {fill(t.viewAll, { entity })}
                  </Button>
                </div>
              </>
            ) : (
              <>
                <span className="hidden sm:block" />
                <Button variant="outline" size="sm" iconLeft={paused ? 'play' : 'pause'} onClick={() => setPaused(!paused)}>
                  {paused ? t.resume : t.pause}
                </Button>
              </>
            )}
          </Footer>
        </section>
      </div>
      <style>{'@keyframes kitw-sweep{from{transform:translateX(-100%)}to{transform:translateX(300%)}}'}</style>
      <Live text={done ? t.done : ''} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* paste                                                               */
/* ------------------------------------------------------------------ */

function parse(text: string) {
  const lines = text.replace(/\r/g, '').split('\n').filter((l) => l.trim());
  if (!lines.length) return { sep: '', rows: [] as string[][] };
  const first = lines[0];
  const sep = first.includes('\t') ? '\t' : (first.match(/;/g) ?? []).length > (first.match(/,/g) ?? []).length ? ';' : ',';
  return { sep, rows: lines.map((l) => l.split(sep).map((c) => c.trim().replace(/^"|"$/g, ''))) };
}

function Paste({ t, title, subtitle, defaultText, onAction }: P) {
  const id = useId();
  const [text, setText] = useState(defaultText ?? '');
  const [header, setHeader] = useState(true);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const [rowRef, fade] = useScrollFade<HTMLDivElement>();
  const { sep, rows } = useMemo(() => parse(text), [text]);
  const head = header ? rows[0] ?? [] : (rows[0] ?? []).map((_, i) => `${String.fromCharCode(65 + i)}`);
  const body = header ? rows.slice(1) : rows;
  const cols = Math.max(0, ...rows.map((r) => r.length));
  const sepName = sep === '\t' ? t.tab : sep === ';' ? t.semicolon : t.comma;
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:items-start">
        <section className="kitm-up min-w-0 rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
          <h2 className="text-[14px] font-semibold text-foreground">{t.paste}</h2>
          <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{t.pasteText}</p>
          <label htmlFor={`${id}-t`} className="sr-only">
            {t.pasteLabel}
          </label>
          <textarea id={`${id}-t`} rows={11} spellCheck={false} wrap="off" value={text} onChange={(e) => setText(e.target.value)} placeholder={t.pastePlaceholder} className="mt-4 w-full resize-y rounded-lg border border-input bg-surface px-3.5 py-3 text-[13px] leading-[1.7] tabular-nums text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 kit-no-scrollbar [tab-size:14] focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]" />
          <div className="mt-3 flex flex-wrap items-center justify-between gap-3">
            <label className="flex min-h-11 cursor-pointer items-center gap-2.5 text-[13px] text-foreground sm:min-h-0">
              <input type="checkbox" checked={header} onChange={(e) => setHeader(e.target.checked)} className="size-4 accent-[var(--primary)]" />
              {t.firstHeader}
            </label>
            {text && (
              <button type="button" onClick={() => setText('')} className="h-11 rounded-md px-2 text-[13px] font-medium text-muted-foreground hover:text-foreground sm:h-8">
                {t.clear}
              </button>
            )}
          </div>
        </section>
        <section aria-live="polite" className="kitm-up [--kitm-d:100ms] min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
            <h2 className="text-[14px] font-semibold text-foreground">{t.preview}</h2>
            {rows.length > 0 && (
              <span key={`${body.length}-${cols}`} className="kitm-in text-[12.5px] tabular-nums text-muted-foreground">
                {fill(t.detected, { rows: body.length, cols, sep: sepName })}
              </span>
            )}
          </div>
          {rows.length ? (
            <div ref={rowRef} tabIndex={0} aria-label={t.preview} className={cn('kit-no-scrollbar overflow-x-auto outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]', fade)}>
              <table className="w-full min-w-[32rem] border-collapse text-left text-[13px]">
                <thead>
                  <tr className="border-b border-border bg-surface">
                    {Array.from({ length: cols }, (_, i) => (
                      <th key={i} scope="col" className="whitespace-nowrap px-4 py-2.5 text-[12px] font-semibold text-foreground">
                        {head[i] ?? ''}
                      </th>
                    ))}
                  </tr>
                </thead>
                <tbody className="divide-y divide-border">
                  {body.slice(0, 8).map((r, i) => (
                    <tr key={i} className="kitm-in" style={{ '--kitm-d': `${i * 35}ms` } as React.CSSProperties}>
                      {Array.from({ length: cols }, (_, j) => (
                        <td key={j} className={cn('max-w-[14rem] truncate px-4 py-2.5 tabular-nums', r[j] ? 'text-foreground' : 'text-subtle-foreground')}>
                          {r[j] || '·'}
                        </td>
                      ))}
                    </tr>
                  ))}
                </tbody>
              </table>
              {body.length > 8 && <p className="border-t border-border px-4 py-2.5 text-[12.5px] text-muted-foreground">{fill(t.moreRows, { n: body.length - 8 })}</p>}
            </div>
          ) : (
            <div className="grid place-items-center px-4 py-16 text-center">
              <Icon name="clipboard" size={26} className="text-subtle-foreground" />
              <p className="mt-3 text-[13px] text-muted-foreground">{t.pasteEmpty}</p>
            </div>
          )}
          <div className="flex justify-end border-t border-border bg-surface px-4 py-3.5 sm:px-5">
            <Button size="sm" icon="arrow-right" disabled={!body.length} onClick={() => void onAction?.('paste', { rows: body.length })} className="max-sm:w-full">
              {fill(t.useData, { n: body.length })}
            </Button>
          </div>
        </section>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* ImportWizard                                                        */
/* ------------------------------------------------------------------ */

/**
 * Import screens for apps: upload with a dropzone and recent imports,
 * column matching with automatic suggestions, a check of every row with
 * fixes in place, the import with live progress and pasting from a
 * spreadsheet with a preview.
 *   <ImportWizard variant="column-mapping" title="Import contacts" fields={…} columns={…} />
 */
export function ImportWizard({ variant = 'upload', sidebar, labels, className, entity = 'records', ...props }: ImportWizardProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const p: P = { ...props, t, locale, entity };
  const body = (() => {
    switch (variant) {
      case 'column-mapping':
        return <Mapping {...p} />;
      case 'review':
        return <Review {...p} />;
      case 'progress':
        return <Progress {...p} />;
      case 'paste':
        return <Paste {...p} />;
      default:
        return <Upload {...p} />;
    }
  })();
  if (!sidebar)
    return (
      <div className={cn('min-h-dvh bg-background', className)}>
        <KitmStyle />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <KitmStyle />
      {body}
    </Sidebar>
  );
}

Frequent questions

How do I install Import wizard?

With aiw add @ai-website/import-wizard. The command puts components/ui/import-wizard.tsx into your project, the code is yours from then on. It brings along button, icon and sidebar. It needs no further npm packages.

Which variants does Import wizard have?

5 variants, Upload, Column matching, Row check, Import progress and Paste from a spreadsheet. You pick one with the variant prop, for example variant="column-mapping".

Can I translate the texts of Import wizard?

Yes. Import wizard carries 68 texts of its own in English and German, such as “Import steps”, “Upload” and “Match columns”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Import wizard?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Import wizard with aiw_components and install it with aiw_add @ai-website/import-wizard. Name the variant in your prompt, for example variant="upload", and the assistant writes your content straight into the props.

Can I adjust Import wizard without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching ImportWizard code to copy.

Often searched

Jump to

Copied to the clipboard