"use client"; import { useState, useRef, useCallback } from "react"; import { importContactsBatch, previewContactImport, type ContactImportEntry, type ImportPreviewResult, type ColumnMapping, type ImportField, } from "@/actions/communications/contacts"; type Step = "idle" | "preview" | "confirming" | "importing" | "result"; type ImportResult = { created: number; updated: number; skipped: number; errors: { row: ContactImportEntry; error: string }[]; }; const LARGE_FILE_ROWS = 10_000; const FIELD_LABELS: Record = { email: "Email", phone: "Phone", first_name: "First Name", last_name: "Last Name", full_name: "Full Name", tags: "Tags", email_opt_in: "Email Opt-In", sms_opt_in: "SMS Opt-In", external_id: "External ID", }; export default function ContactImportForm({ brandId }: { brandId: string }) { const [step, setStep] = useState("idle"); const [file, setFile] = useState(null); const [preview, setPreview] = useState(null); const [rawHeaders, setRawHeaders] = useState([]); const [rawRows, setRawRows] = useState([]); const [importRows, setImportRows] = useState([]); const [importing, setImporting] = useState(false); const [result, setResult] = useState(null); const [globalError, setGlobalError] = useState(""); const [overridingMappings, setOverridingMappings] = useState< Record >({}); const fileRef = useRef(null); // ── CSV parsing + preview ────────────────────────────────────────────────── const handleFile = useCallback( async (f: File) => { setFile(f); setResult(null); setGlobalError(""); setOverridingMappings({}); const text = await f.text(); const previewResult = await previewContactImport(text); if (!previewResult.success) { setGlobalError(previewResult.error); setStep("idle"); return; } const { csv, totalRows } = await import("@/lib/csv-parser").then( (m) => m.parseCSVWithLimits(text) ); setRawHeaders(csv.headers); setRawRows(csv.rows); setPreview(previewResult.preview); setStep("preview"); }, [] ); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f && f.name.endsWith(".csv")) handleFile(f); else setGlobalError("Please upload a .csv file"); }, [handleFile] ); const handleFileChange = useCallback( (e: React.ChangeEvent) => { const f = e.target.files?.[0]; if (f) handleFile(f); }, [handleFile] ); // ── Mapping override ─────────────────────────────────────────────────────── function applyMappings( mappings: ColumnMapping[], overrides: Record, headers: string[], rows: string[][] ): ContactImportEntry[] { const effective = mappings.map((m) => ({ ...m, field: overrides[m.csvColumn] ?? m.field, })); const emailColIdx = effective.findIndex((m) => m.field === "email"); const phoneColIdx = effective.findIndex((m) => m.field === "phone"); const seenEmails = new Set(); const seenPhones = new Set(); const entries: ContactImportEntry[] = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const entry: ContactImportEntry = {}; const ignored: Record = {}; for (const mapping of effective) { const colIdx = headers.indexOf(mapping.csvColumn); if (colIdx === -1) continue; const raw = row[colIdx] ?? ""; if (mapping.field === null) { if (raw) ignored[mapping.csvColumn] = raw; continue; } switch (mapping.field) { case "email": { const cleaned = raw.trim().toLowerCase(); entry.email = cleaned || undefined; break; } case "phone": { const stripped = raw.replace(/[\s\-().[\]]/g, ""); entry.phone = raw.trim() || undefined; break; } case "first_name": entry.first_name = raw || undefined; break; case "last_name": entry.last_name = raw || undefined; break; case "full_name": entry.full_name = raw || undefined; break; case "tags": entry.tags = raw ? raw.split(/[;:,]/).map((t) => t.trim()).filter(Boolean) : undefined; break; case "email_opt_in": entry.email_opt_in = raw === "true" || raw === "1" || raw === "yes" || raw === "y" || raw === "on"; break; case "sms_opt_in": entry.sms_opt_in = raw === "true" || raw === "1" || raw === "yes" || raw === "y" || raw === "on"; break; case "external_id": entry.external_id = raw || undefined; break; } } const hasEmail = !!entry.email; const hasPhone = !!entry.phone; if (!hasEmail && !hasPhone) continue; // Dedupe within file let isDup = false; if (entry.email) { if (seenEmails.has(entry.email!)) isDup = true; seenEmails.add(entry.email!); } if (entry.phone && !isDup) { if (seenPhones.has(entry.phone)) isDup = true; seenPhones.add(entry.phone); } if (isDup) continue; if (Object.keys(ignored).length > 0) { entry._metadata = ignored; } entries.push(entry); } return entries; } // ── Confirm and import ───────────────────────────────────────────────────── const handleConfirm = useCallback( async () => { if (!preview || rawRows.length === 0) return; setStep("importing"); const rowsToImport = applyMappings( preview.mappings, overridingMappings, rawHeaders, rawRows ); setImportRows(rowsToImport); setImporting(true); const importResult = await importContactsBatch({ brandId, contacts: rowsToImport, }); setImporting(false); if (importResult.success) { setResult(importResult.result); setStep("result"); } else { setGlobalError(importResult.error ?? "Import failed"); setStep("result"); } }, [brandId, preview, overridingMappings, rawHeaders, rawRows] ); const handleReset = () => { setStep("idle"); setFile(null); setPreview(null); setImportRows([]); setResult(null); setGlobalError(""); setOverridingMappings({}); if (fileRef.current) fileRef.current.value = ""; }; // ── Render helpers ──────────────────────────────────────────────────────── function renderMappings() { if (!preview) return null; return (
{preview.mappings.map((m) => ( ))}
CSV Column Maps To Sample Values
{m.csvColumn} {m.sampleValues.length > 0 ? m.sampleValues.join(", ") : "—"}
{preview.ignoredColumns.length > 0 && (

Extra columns (stored in metadata): {" "} {preview.ignoredColumns.join(", ")}

)}
); } function renderStats() { if (!preview) return null; return (
0} /> 0} />
); } function renderSampleRows() { if (!preview) return null; return (
{preview.sampleRows.map((row, i) => ( ))}
# email phone first_name last_name full_name email_opt_in sms_opt_in tags
{i + 1} {row.email ?? ""} {row.phone ?? ""} {row.first_name ?? ""} {row.last_name ?? ""} {row.full_name ?? ""} {row.email_opt_in === undefined ? "" : row.email_opt_in ? "true" : "false"} {row.sms_opt_in === undefined ? "" : row.sms_opt_in ? "true" : "false"} {row.tags?.join("; ") ?? ""}
); } // ── Main render ──────────────────────────────────────────────────────────── return (

Import Contacts

{step !== "idle" && ( )}
{globalError && (
{globalError}
)} {/* ── STEP: idle ─────────────────────────────────────────── */} {step === "idle" && ( <>
{ e.preventDefault(); }} onDrop={handleDrop} >
)} {/* ── STEP: preview ──────────────────────────────────────── */} {step === "preview" && preview && ( <> {preview.warnings.map((w, i) => (
{w}
))}

{file?.name}

Column mappings detected — review below, adjust if needed, then confirm import.

{renderMappings()}

Import Summary

{renderStats()}

Sample rows (first {preview.sampleRows.length})

{renderSampleRows()}
{preview.skippedReasons.length > 0 && (

Skipped rows ({preview.skippedReasons.length})

{preview.skippedReasons.slice(0, 10).map((s) => (

Row {s.rowIndex + 1}: {s.reason}

))} {preview.skippedReasons.length > 10 && (

...and{" "} {preview.skippedReasons.length - 10} more

)}
)} {preview.totalRows > LARGE_FILE_ROWS && (
Large file ({preview.totalRows.toLocaleString()} rows) — confirm to proceed.
)}
)} {/* ── STEP: importing ────────────────────────────────────── */} {step === "importing" && (
Importing {importRows.length} contacts...
)} {/* ── STEP: result ────────────────────────────────────────── */} {step === "result" && result && (

Import complete

Created: {result.created}

Updated: {result.updated}

Skipped: {result.skipped}

{result.errors.length > 0 && (

Errors ({result.errors.length})

{result.errors.slice(0, 10).map((e, i) => (

{e.error}: {JSON.stringify(e.row).slice(0, 80)}

))} {result.errors.length > 10 && (

...and {result.errors.length - 10} more

)}
)}
)}
); } function Stat({ label, value, highlight, warn, }: { label: string; value: number; highlight?: boolean; warn?: boolean; }) { return (

{value.toLocaleString()}

{label}

); }