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
Variants
Details
What Import wizard brings along, read from the registry and the source.
- Category
- Import, export and history
- Area
- App
- Variants
- 5
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/import-wizard.tsx
- npm packages
- None
- Languages
- English and German, 68 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ImportWizardProps interface from components/ui/import-wizard.tsx, with the comments from the code.
variantoptionalImportWizardVariantuploadcolumn-mappingreviewprogresspaste
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlerequiredstringsubtitleoptionalstringentityoptionalstringWhat gets imported, e.g. contacts
fieldsoptionalImportField[]Fields of the target
columnsoptionalImportColumn[]column-mapping: the columns found in the file
rowsoptionalRecord<string, string>[]review: rows as field id to value
historyoptionalPastImport[]upload
fileNameoptionalstringFile name on screen
defaultTextoptionalstringpaste: text that starts in the field
totaloptionalnumberprogress: 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
| Key | English | German |
|---|---|---|
| steps | English Import steps | German Schritte des Imports |
| stepUpload | English Upload | German Hochladen |
| stepMap | English Match columns | German Spalten zuordnen |
| stepReview | English Check | German Prüfen |
| stepImport | English Import | German Importieren |
| drop | English Drop a CSV or Excel file here | German CSV- oder Excel-Datei hier ablegen |
| dropActive | English Let go to upload | German Loslassen zum Hochladen |
| or | English or | German oder |
| browse | English Choose a file | German Datei auswählen |
| formats | English CSV, XLSX or TSV, up to 20 MB and 50,000 rows. | German CSV, XLSX oder TSV, bis 20 MB und 50.000 Zeilen. |
| template | English Download a template | German Vorlage herunterladen |
| tryExample | English Try it with {file} | German Mit {file} ausprobieren |
| templateText | English A file with the right columns and two example rows. | German Eine Datei mit den richtigen Spalten und zwei Beispielzeilen. |
| reading | English Reading {file}… | German {file} wird gelesen… |
| rowsFound | English {n} rows and {c} columns found in {file}. | German {n} Zeilen und {c} Spalten in {file} gefunden. |
| next | English Continue | German Weiter |
| back | English Back | German Zurück |
| wrongType | English This file type cannot be read. Use CSV, XLSX or TSV. | German Dieser Dateityp lässt sich nicht lesen. Nutze CSV, XLSX oder TSV. |
| recent | English Recent imports | German Letzte Importe |
| rowsN | English {n} rows | German {n} Zeilen |
| errorsN | English {n} skipped | German {n} übersprungen |
| by | English by {name} | German von {name} |
| column | English Column in your file | German Spalte in deiner Datei |
| field | English Field | German Feld |
| sample | English Example | German Beispiel |
| skip | English Do not import | German Nicht importieren |
| auto | English Matched | German Erkannt |
| required | English Required | German Pflicht |
| missing | English Still to match {fields} | German Noch offen {fields} |
| allSet | English Every required field has a column. | German Jedes Pflichtfeld hat eine Spalte. |
| mapped | English {n} of {total} columns matched | German {n} von {total} Spalten zugeordnet |
| usedBy | English Already used by {col} | German Schon belegt durch {col} |
| mapFor | English Field for {col} | German Feld für {col} |
| errorsOnly | English Only rows with problems | German Nur Zeilen mit Problemen |
| problems | English {n} problems in {r} rows | German {n} Probleme in {r} Zeilen |
| noProblems | English No problems. Every row can be imported. | German Keine Probleme. Jede Zeile kann importiert werden. |
| fixHint | English Click a marked cell to fix it. | German Klicke auf eine markierte Zelle, um sie zu korrigieren. |
| row | English Row {n} | German Zeile {n} |
| edit | English Edit {field} in row {n} | German {field} in Zeile {n} bearbeiten |
| skipRows | English Import {n} rows, skip {m} | German {n} Zeilen importieren, {m} überspringen |
| importAll | English Import {n} rows | German {n} Zeilen importieren |
| importing | English Importing {entity} | German {entity} werden importiert |
| processed | English {n} of {total} rows | German {n} von {total} Zeilen |
| left | English About {s} seconds left | German Noch etwa {s} Sekunden |
| pause | English Pause | German Anhalten |
| resume | English Resume | German Fortsetzen |
| paused | English Paused | German Angehalten |
| done | English Import finished | German Import abgeschlossen |
| created | English Created | German Neu angelegt |
| updated | English Updated | German Aktualisiert |
| skipped | English Skipped | German Übersprungen |
| report | English Download error report | German Fehlerbericht herunterladen |
| viewAll | English Show imported {entity} | German Importierte {entity} ansehen |
| again | English Import another file | German Weitere Datei importieren |
| paste | English Paste from a spreadsheet | German Aus einer Tabelle einfügen |
| pasteText | English 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. |
| pasteLabel | English Data | German Daten |
| pastePlaceholder | English Name Email Company Mia Brandt mia@… | German Name E-Mail Firma Mia Brandt mia@… |
| pasteEmpty | English Paste to see a preview. | German Einfügen, um eine Vorschau zu sehen. |
| detected | English {rows} rows, {cols} columns, separated by {sep} | German {rows} Zeilen, {cols} Spalten, getrennt durch {sep} |
| tab | English tabs | German Tabulatoren |
| comma | English commas | German Kommas |
| semicolon | English semicolons | German Semikolons |
| preview | English Preview | German Vorschau |
| firstHeader | English First line is the header | German Erste Zeile ist die Kopfzeile |
| moreRows | English {n} more rows | German {n} weitere Zeilen |
| useData | English Use these {n} rows | German Diese {n} Zeilen verwenden |
| clear | English Clear | German 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
'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.