'use client'; import { useMemo, useRef, useState } from 'react'; import { createClient } from '@/lib/supabase/client'; import { useAuth } from '@/hooks/use-auth'; import { dedupeByPhone, isUniqueViolation, normalizeKey, } from '@/lib/contacts/dedupe'; import { parseContactCsv, type ParsedContactRow, } from '@/lib/contacts/parse-contact-csv'; import { assignImportedContactTags, resolveImportTagIds, type ContactTagAssignment, } from '@/lib/contacts/resolve-import-tags'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Upload, FileText, Loader2, CheckCircle, XCircle, AlertTriangle, Tag, } from 'lucide-react'; import { useTranslations } from 'next-intl'; const DEFAULT_TAG_COLOR = '#3b82f6'; const PREVIEW_LIMIT = 5; function truncateFilename(name: string, max = 48): string { if (name.length <= max) return name; const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : ''; const base = name.slice(0, name.length - ext.length); const keep = max - ext.length - 1; return `${base.slice(0, Math.max(keep, 12))}…${ext}`; } function PreviewCell({ value, mono, maxWidth = 'max-w-[9rem]', }: { value: string; mono?: boolean; maxWidth?: string; }) { return ( {value} ); } function ImportPreviewTags({ tagNames, tagColorByKey, }: { tagNames: string[]; tagColorByKey: Map; }) { const t = useTranslations('Contacts.importModal'); if (tagNames.length === 0) { return —; } return (
{tagNames.map((name) => { const color = tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR; const isKnown = tagColorByKey.has(name.trim().toLowerCase()); return ( {name} ); })}
); } interface ImportModalProps { open: boolean; onOpenChange: (open: boolean) => void; onImported: () => void; } export function ImportModal({ open, onOpenChange, onImported, }: ImportModalProps) { const t = useTranslations('Contacts.importModal'); const supabase = createClient(); const { accountId, canEditSettings } = useAuth(); const fileInputRef = useRef(null); const [file, setFile] = useState(null); const [parsedRows, setParsedRows] = useState([]); const [hasTagsColumn, setHasTagsColumn] = useState(false); const [hasCompanyColumn, setHasCompanyColumn] = useState(false); const [tagColorByKey, setTagColorByKey] = useState>( new Map() ); const [importing, setImporting] = useState(false); const [result, setResult] = useState<{ imported: number; skipped: number; failed: number; tagsAssigned: number; } | null>(null); function reset() { setFile(null); setParsedRows([]); setHasTagsColumn(false); setHasCompanyColumn(false); setTagColorByKey(new Map()); setResult(null); if (fileInputRef.current) fileInputRef.current.value = ''; } function handleOpenChange(next: boolean) { if (!next) reset(); onOpenChange(next); } async function handleFileChange(e: React.ChangeEvent) { const selected = e.target.files?.[0]; if (!selected) return; setFile(selected); setResult(null); const text = await selected.text(); const { rows, hasTagsColumn: csvHasTags, hasCompanyColumn: csvHasCompany, } = parseContactCsv(text); if (rows.length === 0) { toast.error(t('toastNoValidRows')); setParsedRows([]); setHasTagsColumn(false); setHasCompanyColumn(false); setTagColorByKey(new Map()); return; } setParsedRows(rows); setHasTagsColumn(csvHasTags); setHasCompanyColumn(csvHasCompany); if (csvHasTags && accountId) { const { data: tags } = await supabase .from('tags') .select('name, color') .eq('account_id', accountId); const colors = new Map(); for (const tag of tags ?? []) { const key = tag.name.trim().toLowerCase(); if (!colors.has(key)) colors.set(key, tag.color); } setTagColorByKey(colors); } else { setTagColorByKey(new Map()); } } async function handleImport() { if (parsedRows.length === 0) return; setImporting(true); try { const { data: { session }, } = await supabase.auth.getSession(); const user = session?.user; if (!user) throw new Error('Not authenticated'); if (!accountId) throw new Error('Your profile is not linked to an account.'); let imported = 0; let skipped = 0; let failed = 0; // 1) De-dupe within the file by normalized phone (keep first). const { unique, duplicates: inFileDupes } = dedupeByPhone(parsedRows); skipped += inFileDupes; // 2) Skip numbers already in this account. One read of the // generated `phone_normalized` column (migration 022) → Set. const { data: existingRows } = await supabase .from('contacts') .select('phone_normalized') .eq('account_id', accountId); const existing = new Set( (existingRows ?? []) .map( (r) => (r as { phone_normalized: string | null }).phone_normalized ) .filter((p): p is string => !!p) ); const toInsert = unique.filter((row) => { if (existing.has(normalizeKey(row.phone))) { skipped++; return false; } return true; }); // 3) Resolve tag names → ids (admin+ may auto-create missing tags). // Skip the round-trip when the import carries no tag names. const allTagNames = toInsert.flatMap((row) => row.tagNames); let tagIdByKey = new Map(); let skippedNames: string[] = []; if (allTagNames.length > 0) { ({ tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, { accountId, userId: user.id, tagNames: allTagNames, canCreateTags: canEditSettings, })); } const tagAssignments: ContactTagAssignment[] = []; // 4) Batch insert the genuinely-new rows in chunks of 50. The DB // unique index is the backstop: a 23505 (race, or a format // that normalizes equal) counts as skipped, not failed. const chunkSize = 50; for (let i = 0; i < toInsert.length; i += chunkSize) { const chunk = toInsert.slice(i, i + chunkSize); const rows = chunk.map((row) => ({ user_id: user.id, account_id: accountId, phone: row.phone, name: row.name || null, email: row.email || null, company: row.company || null, })); const { data, error } = await supabase .from('contacts') .insert(rows) .select('id'); if (error) { // Retry individually so one bad/duplicate row doesn't sink // the whole chunk. for (let j = 0; j < rows.length; j++) { const row = rows[j]; const source = chunk[j]; const { data: singleData, error: singleErr } = await supabase .from('contacts') .insert(row) .select('id') .single(); if (!singleErr && singleData) { imported++; if (source.tagNames.length > 0) { tagAssignments.push({ contactId: singleData.id, tagNames: source.tagNames, }); } } else if (isUniqueViolation(singleErr)) { skipped++; } else { failed++; } } } else { const inserted = data ?? []; imported += inserted.length; // inserted[j] ↔ chunk[j] only holds because a single INSERT // preserves RETURNING order. If this path is ever split into // parallel inserts, zip by phone or returned id instead. for (let j = 0; j < inserted.length; j++) { const source = chunk[j]; if (!source || source.tagNames.length === 0) continue; tagAssignments.push({ contactId: inserted[j].id, tagNames: source.tagNames, }); } } } // 5) Wire tags onto the contacts we just created. Failure here must // not mask a successful contact import. let tagsAssigned = 0; try { tagsAssigned = await assignImportedContactTags( supabase, tagAssignments, tagIdByKey ); } catch { toast.warning(t('toastTagsWarning')); } setResult({ imported, skipped, failed, tagsAssigned }); if (imported > 0) { toast.success(t('toastImported', { count: imported })); onImported(); } if (tagsAssigned > 0) { toast.success(t('toastTagsAssigned', { count: tagsAssigned })); } if (skippedNames.length > 0) { const sample = skippedNames.slice(0, 3).join(', '); const more = skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : ''; toast.info(t('toastTagsSkipped', { sample, more })); } if (skipped > 0) { toast.info(t('toastSkipped', { count: skipped })); } if (failed > 0) { toast.error(t('toastFailed', { count: failed })); } } catch (err: unknown) { const message = err instanceof Error ? err.message : t('toastError'); toast.error(message); } finally { setImporting(false); } } const preview = parsedRows.slice(0, PREVIEW_LIMIT); // Tags: OR — show when the CSV declares a column or preview rows carry // values, so an all-empty tags column still renders for validation. const previewHasTags = hasTagsColumn || preview.some((row) => row.tagNames.length > 0); // Company: AND — hide unless the CSV declares it and preview has data, // avoiding an all-dash column that wastes horizontal space. const previewHasCompany = hasCompanyColumn && preview.some((row) => row.company?.trim()); const tagStats = useMemo(() => { const names = new Set(); let rowsWithTags = 0; for (const row of parsedRows) { if (row.tagNames.length === 0) continue; rowsWithTags++; for (const name of row.tagNames) names.add(name.trim().toLowerCase()); } return { unique: names.size, rowsWithTags }; }, [parsedRows]); return (
{t('title')} `${chunks}`, nameCode: (chunks) => `${chunks}`, emailCode: (chunks) => `${chunks}`, companyCode: (chunks) => `${chunks}`, tagsCode: (chunks) => `${chunks}`, }) }} />
fileInputRef.current?.click()} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click(); }} className={cn( 'group flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-5 transition-all', file ? 'border-primary/35 bg-primary/[0.04]' : 'hover:border-primary/40 border-border/80 bg-background/40 hover:bg-background/70' )} > {file ? ( <>

{truncateFilename(file.name)}

{t('rowsReady', { count: parsedRows.length })} ) : ( <>

{t('uploadDropzone')}

{t('uploadHint')}

)}
{preview.length > 0 && !result && (

{t('preview', { count: preview.length })}

{tagStats.rowsWithTags > 0 && ( {t('previewTags', { tags: tagStats.unique, contacts: tagStats.rowsWithTags })} )}
{previewHasCompany && ( )} {previewHasTags && ( )} {preview.map((row, i) => ( {previewHasCompany && ( )} {previewHasTags && ( )} ))}
{t('columns.phone')} {t('columns.name')} {t('columns.email')} {t('columns.company')} {t('columns.tags')}
{parsedRows.length > PREVIEW_LIMIT && (

{t('moreRows', { count: parsedRows.length - PREVIEW_LIMIT })}

)}
)} {result && (

{t('importComplete')}

{result.imported > 0 && (
{t('resultImported', { count: result.imported })}
)} {result.tagsAssigned > 0 && (
{t('resultTags', { count: result.tagsAssigned })}
)} {result.skipped > 0 && (
{t('resultSkipped', { count: result.skipped })}
)} {result.failed > 0 && (
{t('resultFailed', { count: result.failed })}
)}
)}
{!result && ( )}
); }