"use client"; import { useState, useRef, useCallback, type Dispatch, type SetStateAction, } from "react"; import { importContactsBatch, previewContactImport, type ContactImportEntry, type ImportPreviewResult, type ColumnMapping, type ImportField, } from "@/actions/communications/contacts"; import { uploadContactsToBucket, processBucketImport, listImportHistory } from "@/actions/communications/import-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", }; // Icon components const Icons = { upload: (className: string) => ( ), uploadCloud: (className: string) => ( ), file: (className: string) => ( ), check: (className: string) => ( ), spinner: (className: string) => ( ), }; // Helper for stat colors function getStatColor(highlight: boolean, warn: boolean) { if (highlight) return "bg-emerald-50 border-emerald-200 text-emerald-700"; if (warn) return "bg-amber-50 border-amber-200 text-amber-700"; return "bg-[var(--admin-card)] border-[var(--admin-border)] text-[var(--admin-text-primary)]"; } function getStatValueColor(highlight: boolean, warn: boolean) { if (highlight) return "text-emerald-600"; if (warn) return "text-amber-600"; return "text-[var(--admin-text-muted)]"; } export default function ContactImportForm({ brandId }: { brandId: string }) { const [step, setStep] = useState("idle"); const [file, setFile] = useState(null); const [preview, setPreview] = useState(null); const rawHeadersRef = useRef([]); const setRawHeaders = (v: string[]) => { rawHeadersRef.current = v; }; const rawRowsRef = useRef([]); const setRawRows = (v: string[][]) => { rawRowsRef.current = v; }; const [importRows, setImportRows] = useState([]); const [importing, setImporting] = useState(false); const [result, setResult] = useState(null); const [globalError, setGlobalError] = useState(""); const [overridingMappings, setOverridingMappings] = useState< Record >({}); const [uploadProgress, setUploadProgress] = useState(""); const [importHistory, setImportHistory] = useState<{ filename: string; size: number; createdAt: string }[]>([]); const fileRef = useRef(null); // For large file detection (farmers with lots of data) const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; // 5MB // ── CSV parsing + preview ────────────────────────────────────────────────── const handleFile = useCallback( async (f: File) => { setFile(f); setResult(null); setGlobalError(""); setOverridingMappings({}); // Check if file is large enough to warrant bucket upload const isLargeFile = f.size > LARGE_FILE_THRESHOLD; if (isLargeFile) { // Use bucket upload for large files setUploadProgress("Uploading file to storage..."); const uploadResult = await uploadContactsToBucket(brandId, f); if (!uploadResult.success) { setGlobalError(uploadResult.error); setStep("idle"); return; } setUploadProgress(`Processing ${uploadResult.recordCount.toLocaleString()} records...`); // Process from bucket const processResult = await processBucketImport(brandId, uploadResult.fileUrl); setImporting(false); setUploadProgress(""); if (!processResult.success) { setGlobalError(processResult.error); setStep("idle"); return; } setResult({ created: processResult.created, updated: processResult.updated, skipped: processResult.skipped, errors: [], }); setStep("result"); // Load import history const historyResult = await listImportHistory(brandId); if (historyResult.success) { setImportHistory(historyResult.imports); } return; } // Browser-based processing for smaller files 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"); }, [brandId, LARGE_FILE_THRESHOLD] ); 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[] = []; const headerIndex = new Map(); headers.forEach((h, i) => headerIndex.set(h, i)); 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 = headerIndex.get(mapping.csvColumn); if (colIdx === undefined) 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(/[;:,]/).flatMap((t) => { const trimmed = t.trim(); return trimmed ? [trimmed] : []; }) : 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 || rawRowsRef.current.length === 0) return; setStep("importing"); const rowsToImport = applyMappings( preview.mappings, overridingMappings, rawHeadersRef.current, rawRowsRef.current ); 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] ); const handleReset = () => { setStep("idle"); setFile(null); setPreview(null); setImportRows([]); setResult(null); setGlobalError(""); setOverridingMappings({}); if (fileRef.current) fileRef.current.value = ""; }; // ── Main render ──────────────────────────────────────────────────────────── return (
{/* Header */}
{Icons.uploadCloud("w-3 h-3 text-[var(--admin-bg)]")}

Import Contacts

{step !== "idle" && ( )}
{/* Large file banner */} {file && file.size > LARGE_FILE_THRESHOLD && (
{Icons.check("h-4 w-4 text-emerald-600")}

Large file detected

File will be uploaded to cloud storage for processing

)} {/* Upload progress */} {uploadProgress && (
{Icons.spinner("h-5 w-5 text-blue-600 animate-spin")} {uploadProgress}
)} {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.

{preview ? ( ) : null}

Import Summary

{preview ? : null}

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

{preview ? : null}
{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 && (

{Icons.check("h-4 w-4")} Import complete

Created: {result.created.toLocaleString()}

Updated: {result.updated.toLocaleString()}

Skipped: {result.skipped.toLocaleString()}

{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

)}
)}
)} {/* Import history for bucket imports */} {importHistory.length > 0 && (

Previous Imports

{importHistory.slice(0, 5).map((imp, i) => (
{Icons.file("h-4 w-4 text-[var(--admin-text-muted)]")} {imp.filename}
{(imp.size / 1024).toFixed(1)} KB
))}
)}
); } function Stat({ label, value, highlight, warn, }: { label: string; value: number; highlight?: boolean; warn?: boolean; }) { const bgColor = highlight ? "bg-emerald-50 border-emerald-200" : warn ? "bg-amber-50 border-amber-200" : "bg-[var(--admin-card)] border-[var(--admin-border)]"; const valueColor = highlight ? "text-emerald-700" : warn ? "text-amber-700" : "text-[var(--admin-text-primary)]"; const labelColor = highlight ? "text-emerald-600" : warn ? "text-amber-600" : "text-[var(--admin-text-muted)]"; return (

{value.toLocaleString()}

{label}

); } // ── Module-scope sub-views ─────────────────────────────────────────────────── function MappingsView({ preview, overridingMappings, setOverridingMappings, }: { preview: ImportPreviewResult; overridingMappings: Record; setOverridingMappings: Dispatch>>; }) { 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 StatsView({ preview }: { preview: ImportPreviewResult }) { return (
0} /> 0} />
); } function SampleRowsView({ preview }: { preview: ImportPreviewResult }) { 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("; ") ?? ""}
); }