773 lines
28 KiB
TypeScript
773 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useCallback } 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<string, string> = {
|
|
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) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|
<polyline points="17 8 12 3 7 8"/>
|
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
|
</svg>
|
|
),
|
|
uploadCloud: (className: string) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="16 16 12 12 8 16"/>
|
|
<line x1="12" y1="12" x2="12" y2="21"/>
|
|
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/>
|
|
<polyline points="16 16 12 12 8 16"/>
|
|
</svg>
|
|
),
|
|
file: (className: string) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<polyline points="14 2 14 8 20 8"/>
|
|
</svg>
|
|
),
|
|
check: (className: string) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
),
|
|
spinner: (className: string) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"/>
|
|
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round">
|
|
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
|
|
</path>
|
|
</svg>
|
|
),
|
|
};
|
|
|
|
// 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<Step>("idle");
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [preview, setPreview] = useState<ImportPreviewResult | null>(null);
|
|
const [rawHeaders, setRawHeaders] = useState<string[]>([]);
|
|
const [rawRows, setRawRows] = useState<string[][]>([]);
|
|
const [importRows, setImportRows] = useState<ContactImportEntry[]>([]);
|
|
const [importing, setImporting] = useState(false);
|
|
const [result, setResult] = useState<ImportResult | null>(null);
|
|
const [globalError, setGlobalError] = useState("");
|
|
const [overridingMappings, setOverridingMappings] = useState<
|
|
Record<string, ImportField>
|
|
>({});
|
|
const [useBucket, setUseBucket] = useState(false); // Toggle for bucket upload
|
|
const [uploadProgress, setUploadProgress] = useState<string>("");
|
|
const [importHistory, setImportHistory] = useState<{ filename: string; size: number; createdAt: string }[]>([]);
|
|
const fileRef = useRef<HTMLInputElement>(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...");
|
|
setUseBucket(true);
|
|
|
|
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
|
|
setUseBucket(false);
|
|
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<HTMLInputElement>) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) handleFile(f);
|
|
},
|
|
[handleFile]
|
|
);
|
|
|
|
// ── Mapping override ───────────────────────────────────────────────────────
|
|
|
|
function applyMappings(
|
|
mappings: ColumnMapping[],
|
|
overrides: Record<string, ImportField>,
|
|
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<string>();
|
|
const seenPhones = new Set<string>();
|
|
const entries: ContactImportEntry[] = [];
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const row = rows[i];
|
|
const entry: ContactImportEntry = {};
|
|
const ignored: Record<string, string> = {};
|
|
|
|
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 (
|
|
<div className="space-y-3">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs border border-[var(--admin-border)] rounded-lg">
|
|
<thead className="bg-[var(--admin-card)]">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
|
|
CSV Column
|
|
</th>
|
|
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
|
|
Maps To
|
|
</th>
|
|
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
|
|
Sample Values
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{preview.mappings.map((m) => (
|
|
<tr key={m.csvColumn} className="border-t border-[var(--admin-border)]">
|
|
<td className="px-3 py-2 text-[var(--admin-text-primary)] font-mono text-xs">
|
|
{m.csvColumn}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<select
|
|
value={
|
|
overridingMappings[m.csvColumn] ?? m.field ?? "ignore"
|
|
}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setOverridingMappings((prev) => ({
|
|
...prev,
|
|
[m.csvColumn]:
|
|
val === "ignore"
|
|
? null
|
|
: (val as ImportField),
|
|
}));
|
|
}}
|
|
className="text-xs border border-[var(--admin-border)] rounded px-2 py-1 bg-white text-[var(--admin-text-primary)]"
|
|
>
|
|
<option value="ignore">— ignore —</option>
|
|
{Object.entries(FIELD_LABELS).map(([k, label]) => (
|
|
<option key={k} value={k}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
<td className="px-3 py-2 text-[var(--admin-text-muted)] text-xs">
|
|
{m.sampleValues.length > 0
|
|
? m.sampleValues.join(", ")
|
|
: "—"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{preview.ignoredColumns.length > 0 && (
|
|
<p className="text-xs text-[var(--admin-text-muted)]">
|
|
Extra columns (stored in metadata):
|
|
<span className="font-mono">
|
|
{" "}
|
|
{preview.ignoredColumns.join(", ")}
|
|
</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderStats() {
|
|
if (!preview) return null;
|
|
return (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<Stat label="Total rows" value={preview.totalRows} />
|
|
<Stat
|
|
label="Importable"
|
|
value={preview.validRows}
|
|
highlight
|
|
/>
|
|
<Stat label="Skipped" value={preview.skippedRows} warn={preview.skippedRows > 0} />
|
|
<Stat
|
|
label="Duplicates"
|
|
value={preview.duplicateRows}
|
|
warn={preview.duplicateRows > 0}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderSampleRows() {
|
|
if (!preview) return null;
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs border border-[var(--admin-border)] rounded-lg">
|
|
<thead className="bg-[var(--admin-card)]">
|
|
<tr>
|
|
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">#</th>
|
|
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">email</th>
|
|
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">phone</th>
|
|
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">first_name</th>
|
|
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">last_name</th>
|
|
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">full_name</th>
|
|
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">email_opt_in</th>
|
|
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">sms_opt_in</th>
|
|
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">tags</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{preview.sampleRows.map((row, i) => (
|
|
<tr key={i} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-card-hover)]">
|
|
<td className="px-2 py-1 text-[var(--admin-text-muted)]">{i + 1}</td>
|
|
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.email ?? ""}</td>
|
|
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.phone ?? ""}</td>
|
|
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.first_name ?? ""}</td>
|
|
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.last_name ?? ""}</td>
|
|
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.full_name ?? ""}</td>
|
|
<td className="px-2 py-1 text-[var(--admin-text-muted)]">
|
|
{row.email_opt_in === undefined
|
|
? ""
|
|
: row.email_opt_in
|
|
? "true"
|
|
: "false"}
|
|
</td>
|
|
<td className="px-2 py-1 text-[var(--admin-text-muted)]">
|
|
{row.sms_opt_in === undefined
|
|
? ""
|
|
: row.sms_opt_in
|
|
? "true"
|
|
: "false"}
|
|
</td>
|
|
<td className="px-2 py-1 text-[var(--admin-text-muted)]">{row.tags?.join("; ") ?? ""}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Main render ────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 space-y-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex h-6 w-6 items-center justify-center rounded bg-[var(--admin-text-primary)]">
|
|
{Icons.uploadCloud("w-3 h-3 text-[var(--admin-bg)]")}
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Import Contacts</h3>
|
|
</div>
|
|
{step !== "idle" && (
|
|
<button
|
|
onClick={handleReset}
|
|
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
|
>
|
|
← Start over
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Large file banner */}
|
|
{file && file.size > LARGE_FILE_THRESHOLD && (
|
|
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm flex items-center gap-3">
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100">
|
|
{Icons.check("h-4 w-4 text-emerald-600")}
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold text-emerald-700">Large file detected</p>
|
|
<p className="text-xs text-emerald-600">File will be uploaded to cloud storage for processing</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Upload progress */}
|
|
{uploadProgress && (
|
|
<div className="rounded-lg bg-blue-50 border border-blue-200 px-4 py-4 text-sm">
|
|
<div className="flex items-center gap-3">
|
|
{Icons.spinner("h-5 w-5 text-blue-600 animate-spin")}
|
|
<span className="text-blue-700 font-medium">{uploadProgress}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{globalError && (
|
|
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
|
{globalError}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── STEP: idle ─────────────────────────────────────────── */}
|
|
{step === "idle" && (
|
|
<>
|
|
<div
|
|
className="border-2 border-dashed border-[var(--admin-border)] rounded-xl p-8 text-center hover:border-emerald-400 transition-colors"
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
}}
|
|
onDrop={handleDrop}
|
|
>
|
|
<input
|
|
ref={fileRef}
|
|
type="file"
|
|
accept=".csv"
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
id="csv-upload"
|
|
/>
|
|
<label htmlFor="csv-upload" className="cursor-pointer">
|
|
<div className="text-[var(--admin-text-muted)] text-sm">
|
|
<span className="font-semibold text-emerald-600 hover:text-emerald-700">
|
|
Click to upload
|
|
</span>
|
|
{" "}or drag and drop a CSV file
|
|
</div>
|
|
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
|
|
Works with Mailchimp, Square, Shopify, WooCommerce, QuickBooks, and
|
|
generic spreadsheets
|
|
</p>
|
|
<p className="text-[10px] text-[var(--admin-text-muted)] mt-2 text-emerald-600">
|
|
Large files (5MB+) automatically uploaded to cloud storage
|
|
</p>
|
|
</label>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ── STEP: preview ──────────────────────────────────────── */}
|
|
{step === "preview" && preview && (
|
|
<>
|
|
{preview.warnings.map((w, i) => (
|
|
<div
|
|
key={i}
|
|
className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
|
|
>
|
|
{w}
|
|
</div>
|
|
))}
|
|
|
|
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm text-emerald-700">
|
|
<p className="font-semibold mb-1">{file?.name}</p>
|
|
<p>
|
|
Column mappings detected — review below, adjust if needed, then
|
|
confirm import.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{renderMappings()}
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
|
|
Import Summary
|
|
</p>
|
|
{renderStats()}
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
|
|
Sample rows (first {preview.sampleRows.length})
|
|
</p>
|
|
{renderSampleRows()}
|
|
</div>
|
|
|
|
{preview.skippedReasons.length > 0 && (
|
|
<div>
|
|
<p className="text-xs text-[var(--admin-text-muted)] mb-1 font-semibold">
|
|
Skipped rows ({preview.skippedReasons.length})
|
|
</p>
|
|
<div className="max-h-32 overflow-y-auto text-xs text-[var(--admin-text-muted)] space-y-1">
|
|
{preview.skippedReasons.slice(0, 10).map((s) => (
|
|
<p key={s.rowIndex}>
|
|
Row {s.rowIndex + 1}: {s.reason}
|
|
</p>
|
|
))}
|
|
{preview.skippedReasons.length > 10 && (
|
|
<p className="text-[var(--admin-text-muted)]">
|
|
...and{" "}
|
|
{preview.skippedReasons.length - 10} more
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{preview.totalRows > LARGE_FILE_ROWS && (
|
|
<div className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
|
|
Large file ({preview.totalRows.toLocaleString()} rows) — confirm to
|
|
proceed.
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => handleReset()}
|
|
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={() => handleConfirm()}
|
|
disabled={preview.validRows === 0}
|
|
className="rounded-lg bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
Import {preview.validRows} contacts
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ── STEP: importing ────────────────────────────────────── */}
|
|
{step === "importing" && (
|
|
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-6 text-center text-sm text-emerald-700 font-semibold">
|
|
Importing {importRows.length} contacts...
|
|
</div>
|
|
)}
|
|
|
|
{/* ── STEP: result ────────────────────────────────────────── */}
|
|
{step === "result" && result && (
|
|
<div className="space-y-3">
|
|
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm space-y-1">
|
|
<p className="font-semibold text-emerald-700 flex items-center gap-2">
|
|
{Icons.check("h-4 w-4")}
|
|
Import complete
|
|
</p>
|
|
<p className="text-emerald-600">Created: {result.created.toLocaleString()}</p>
|
|
<p className="text-emerald-600">Updated: {result.updated.toLocaleString()}</p>
|
|
<p className="text-emerald-600">Skipped: {result.skipped.toLocaleString()}</p>
|
|
</div>
|
|
|
|
{result.errors.length > 0 && (
|
|
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm">
|
|
<p className="font-semibold text-red-600 mb-2">
|
|
Errors ({result.errors.length})
|
|
</p>
|
|
<div className="max-h-40 overflow-y-auto space-y-1">
|
|
{result.errors.slice(0, 10).map((e, i) => (
|
|
<p key={i} className="text-red-600 text-xs">
|
|
{e.error}: {JSON.stringify(e.row).slice(0, 80)}
|
|
</p>
|
|
))}
|
|
{result.errors.length > 10 && (
|
|
<p className="text-red-600 text-xs">
|
|
...and {result.errors.length - 10} more
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={handleReset}
|
|
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
|
|
>
|
|
Import more
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Import history for bucket imports */}
|
|
{importHistory.length > 0 && (
|
|
<div className="border-t border-[var(--admin-border)] pt-4 mt-4">
|
|
<p className="text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide mb-3">
|
|
Previous Imports
|
|
</p>
|
|
<div className="space-y-2">
|
|
{importHistory.slice(0, 5).map((imp, i) => (
|
|
<div key={i} className="flex items-center justify-between text-xs p-2 rounded-lg bg-[var(--admin-card)]">
|
|
<div className="flex items-center gap-2">
|
|
{Icons.file("h-4 w-4 text-[var(--admin-text-muted)]")}
|
|
<span className="text-[var(--admin-text-primary)]">{imp.filename}</span>
|
|
</div>
|
|
<span className="text-[var(--admin-text-muted)]">
|
|
{(imp.size / 1024).toFixed(1)} KB
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className={`rounded-lg border px-4 py-3 text-center ${bgColor}`}>
|
|
<p className={`text-xl font-bold ${valueColor}`}>
|
|
{value.toLocaleString()}
|
|
</p>
|
|
<p className={`text-xs mt-0.5 ${labelColor}`}>{label}</p>
|
|
</div>
|
|
);
|
|
} |