Zum Inhalt springen
UI Kit
Accent

Import-Assistent

Import-Screens. Hochladen mit Ablagefläche, Spalten den Feldern zuordnen, jede Zeile prüfen und direkt korrigieren, Import mit Live-Fortschritt und Einfügen aus einer Tabelle.

Eine von 3 Components in Import, Export und Verlauf

5 Variants

Variants

Hochladen

Eine Ablagefläche, die auf Ziehen reagiert, Lesen mit Fortschritt, Prüfung des Dateityps, Vorlage zum Herunterladen und letzte Importe.

Im Studio öffnen

Spaltenzuordnung

Jede Spalte mit Beispielwerten und dem Feld, das sie füllt, von selbst zugeordnet, wo der Name passt, belegte Felder gesperrt und fehlende Pflichtfelder benannt.

Im Studio öffnen

Zeilenprüfung

Jedes Problem gezählt, Zellen mit Grund markiert, per Klick direkt korrigiert, und ein Knopf, der sagt, wie viele Zeilen importiert werden.

Im Studio öffnen

Importfortschritt

Die Zeilen live hochgezählt mit Restzeit, angelegt, aktualisiert und übersprungen, während sie wachsen, Anhalten und Fortsetzen und das Ergebnis mit Fehlerbericht.

Im Studio öffnen

Aus Tabelle einfügen

Zellen einfügen, das Trennzeichen wird erkannt, die erste Zeile kann Kopfzeile sein, und eine Vorschau zeigt Zeilen und Spalten sofort.

Im Studio öffnen

Details

Was Import-Assistent mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.1 · seit 1.1.0
Datei
components/ui/import-wizard.tsx
Bringt mit
utilskitbuttoniconsidebar
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 68 eigene Texte

Props

Die Schnittstelle ImportWizardProps aus components/ui/import-wizard.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    ImportWizardVariant

    uploadcolumn-mappingreviewprogresspaste

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

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

  • titlePflicht
    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.

Eingebaute Texte

Diese Texte schreibt Import-Assistent selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 68 Texte anzeigen
stepsEnglisch Import stepsDeutsch Schritte des Imports
stepUploadEnglisch UploadDeutsch Hochladen
stepMapEnglisch Match columnsDeutsch Spalten zuordnen
stepReviewEnglisch CheckDeutsch Prüfen
stepImportEnglisch ImportDeutsch Importieren
dropEnglisch Drop a CSV or Excel file hereDeutsch CSV- oder Excel-Datei hier ablegen
dropActiveEnglisch Let go to uploadDeutsch Loslassen zum Hochladen
orEnglisch orDeutsch oder
browseEnglisch Choose a fileDeutsch Datei auswählen
formatsEnglisch CSV, XLSX or TSV, up to 20 MB and 50,000 rows.Deutsch CSV, XLSX oder TSV, bis 20 MB und 50.000 Zeilen.
templateEnglisch Download a templateDeutsch Vorlage herunterladen
tryExampleEnglisch Try it with {file}Deutsch Mit {file} ausprobieren
templateTextEnglisch A file with the right columns and two example rows.Deutsch Eine Datei mit den richtigen Spalten und zwei Beispielzeilen.
readingEnglisch Reading {file}…Deutsch {file} wird gelesen…
rowsFoundEnglisch {n} rows and {c} columns found in {file}.Deutsch {n} Zeilen und {c} Spalten in {file} gefunden.
nextEnglisch ContinueDeutsch Weiter
backEnglisch BackDeutsch Zurück
wrongTypeEnglisch This file type cannot be read. Use CSV, XLSX or TSV.Deutsch Dieser Dateityp lässt sich nicht lesen. Nutze CSV, XLSX oder TSV.
recentEnglisch Recent importsDeutsch Letzte Importe
rowsNEnglisch {n} rowsDeutsch {n} Zeilen
errorsNEnglisch {n} skippedDeutsch {n} übersprungen
byEnglisch by {name}Deutsch von {name}
columnEnglisch Column in your fileDeutsch Spalte in deiner Datei
fieldEnglisch FieldDeutsch Feld
sampleEnglisch ExampleDeutsch Beispiel
skipEnglisch Do not importDeutsch Nicht importieren
autoEnglisch MatchedDeutsch Erkannt
requiredEnglisch RequiredDeutsch Pflicht
missingEnglisch Still to match {fields}Deutsch Noch offen {fields}
allSetEnglisch Every required field has a column.Deutsch Jedes Pflichtfeld hat eine Spalte.
mappedEnglisch {n} of {total} columns matchedDeutsch {n} von {total} Spalten zugeordnet
usedByEnglisch Already used by {col}Deutsch Schon belegt durch {col}
mapForEnglisch Field for {col}Deutsch Feld für {col}
errorsOnlyEnglisch Only rows with problemsDeutsch Nur Zeilen mit Problemen
problemsEnglisch {n} problems in {r} rowsDeutsch {n} Probleme in {r} Zeilen
noProblemsEnglisch No problems. Every row can be imported.Deutsch Keine Probleme. Jede Zeile kann importiert werden.
fixHintEnglisch Click a marked cell to fix it.Deutsch Klicke auf eine markierte Zelle, um sie zu korrigieren.
rowEnglisch Row {n}Deutsch Zeile {n}
editEnglisch Edit {field} in row {n}Deutsch {field} in Zeile {n} bearbeiten
skipRowsEnglisch Import {n} rows, skip {m}Deutsch {n} Zeilen importieren, {m} überspringen
importAllEnglisch Import {n} rowsDeutsch {n} Zeilen importieren
importingEnglisch Importing {entity}Deutsch {entity} werden importiert
processedEnglisch {n} of {total} rowsDeutsch {n} von {total} Zeilen
leftEnglisch About {s} seconds leftDeutsch Noch etwa {s} Sekunden
pauseEnglisch PauseDeutsch Anhalten
resumeEnglisch ResumeDeutsch Fortsetzen
pausedEnglisch PausedDeutsch Angehalten
doneEnglisch Import finishedDeutsch Import abgeschlossen
createdEnglisch CreatedDeutsch Neu angelegt
updatedEnglisch UpdatedDeutsch Aktualisiert
skippedEnglisch SkippedDeutsch Übersprungen
reportEnglisch Download error reportDeutsch Fehlerbericht herunterladen
viewAllEnglisch Show imported {entity}Deutsch Importierte {entity} ansehen
againEnglisch Import another fileDeutsch Weitere Datei importieren
pasteEnglisch Paste from a spreadsheetDeutsch Aus einer Tabelle einfügen
pasteTextEnglisch Copy cells in your spreadsheet and paste them here. The first line is the header.Deutsch Kopiere Zellen in deiner Tabelle und füge sie hier ein. Die erste Zeile ist die Kopfzeile.
pasteLabelEnglisch DataDeutsch Daten
pastePlaceholderEnglisch Name Email Company Mia Brandt mia@…Deutsch Name E-Mail Firma Mia Brandt mia@…
pasteEmptyEnglisch Paste to see a preview.Deutsch Einfügen, um eine Vorschau zu sehen.
detectedEnglisch {rows} rows, {cols} columns, separated by {sep}Deutsch {rows} Zeilen, {cols} Spalten, getrennt durch {sep}
tabEnglisch tabsDeutsch Tabulatoren
commaEnglisch commasDeutsch Kommas
semicolonEnglisch semicolonsDeutsch Semikolons
previewEnglisch PreviewDeutsch Vorschau
firstHeaderEnglisch First line is the headerDeutsch Erste Zeile ist die Kopfzeile
moreRowsEnglisch {n} more rowsDeutsch {n} weitere Zeilen
useDataEnglisch Use these {n} rowsDeutsch Diese {n} Zeilen verwenden
clearEnglisch ClearDeutsch Leeren

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Import-Assistent reagiert selbst auf die Tasten Escape, Enter und Leertaste.
  • Setzt aria-current, aria-label, aria-live, aria-valuemax, aria-valuemin und aria-valuenow, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen progressbar und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.037 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Import-Assistent?

Mit aiw add @ai-website/import-wizard. Der Befehl legt die Datei components/ui/import-wizard.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Import-Assistent?

5 Varianten, Hochladen, Spaltenzuordnung, Zeilenprüfung, Importfortschritt und Aus Tabelle einfügen. Du wählst sie mit der Prop variant, zum Beispiel variant="column-mapping".

Kann ich die Texte von Import-Assistent übersetzen?

Ja. Import-Assistent bringt 68 eigene Texte auf Deutsch und Englisch mit, etwa „Schritte des Imports“, „Hochladen“ und „Spalten zuordnen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Import-Assistent einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Import-Assistent mit aiw_components und installieren sie mit aiw_add @ai-website/import-wizard. Nenn im Prompt die Variante, etwa variant="upload", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Import-Assistent ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ImportWizard-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert