'use client' import { useState, useRef, useCallback, useMemo } from 'react' import { Upload, CheckCircle, AlertCircle, X, ChevronDown, ArrowRight, FileText, RefreshCcw } from 'lucide-react' import { toast } from 'sonner' import { importPaymentsCsv } from '@/lib/actions' // ─── Lightweight CSV parser (handles quoted fields, no external deps) ────────── function parseCSV(text: string): { headers: string[]; rows: string[][] } { const lines: string[] = [] let current = '' let inQuotes = false for (let i = 0; i < text.length; i++) { const ch = text[i] if (ch === '"') { if (inQuotes && text[i + 1] === '"') { current += '"'; i++ } else inQuotes = !inQuotes } else if (ch === '\n' && !inQuotes) { lines.push(current.trimEnd()) current = '' } else if (ch === '\r' && !inQuotes) { // skip } else { current += ch } } if (current.trim()) lines.push(current.trimEnd()) function splitRow(row: string): string[] { const fields: string[] = [] let field = '' let inQ = false for (let i = 0; i < row.length; i++) { const c = row[i] if (c === '"') { if (inQ && row[i + 1] === '"') { field += '"'; i++ } else inQ = !inQ } else if (c === ',' && !inQ) { fields.push(field.trim()) field = '' } else { field += c } } fields.push(field.trim()) return fields } const nonEmpty = lines.filter(l => l.trim()) if (nonEmpty.length === 0) return { headers: [], rows: [] } const headers = splitRow(nonEmpty[0]).map(h => h.replace(/^"|"$/g, '')) const rows = nonEmpty.slice(1).map(l => splitRow(l).map(v => v.replace(/^"|"$/g, ''))) return { headers, rows } } // ─── Import types ───────────────────────────────────────────────────────────── type ImportMode = 'members' | 'payments' type MemberFieldKey = 'display_name' | 'email' | 'phone' | 'handle' | 'tier' | 'joined_at' | 'last_paid_at' | 'has_card_access' | 'skip' type PaymentFieldKey = 'amount' | 'from_identifier' | 'from_note' | 'transaction_date' | 'platform' | 'skip' const MEMBER_FIELDS: { key: MemberFieldKey; label: string; required?: boolean }[] = [ { key: 'display_name', label: 'Display Name', required: true }, { key: 'email', label: 'Email', required: true }, { key: 'phone', label: 'Phone' }, { key: 'handle', label: 'Handle / Username' }, { key: 'tier', label: 'Tier / Membership Type' }, { key: 'joined_at', label: 'Join Date' }, { key: 'last_paid_at', label: 'Last Paid Date' }, { key: 'has_card_access', label: 'Card Access' }, { key: 'skip', label: 'Skip this column' }, ] const PAYMENT_FIELDS: { key: PaymentFieldKey; label: string; required?: boolean }[] = [ { key: 'amount', label: 'Amount', required: true }, { key: 'from_identifier', label: 'Sender / From Name', required: true }, { key: 'transaction_date', label: 'Date', required: true }, { key: 'platform', label: 'Platform (venmo/paypal/cash)' }, { key: 'from_note', label: 'Note / Memo' }, { key: 'skip', label: 'Skip this column' }, ] // Auto-detect column mapping from header names function autoMap(headers: string[], mode: ImportMode): Record { const map: Record = {} const memberPatterns: [RegExp, MemberFieldKey][] = [ [/name|full.?name|display/i, 'display_name'], [/email/i, 'email'], [/phone|mobile/i, 'phone'], [/handle|username|slack|discord/i, 'handle'], [/tier|type|member.?type|plan|level/i, 'tier'], [/join|joined|start/i, 'joined_at'], [/paid|dues|last.?paid/i, 'last_paid_at'], [/card|access|fob|keyfob/i, 'has_card_access'], ] const paymentPatterns: [RegExp, PaymentFieldKey][] = [ [/amount|sum|total|\$/i, 'amount'], [/from|sender|name|payer/i, 'from_identifier'], [/note|memo|desc/i, 'from_note'], [/date|time|when/i, 'transaction_date'], [/platform|source|via/i, 'platform'], ] const patterns = mode === 'members' ? memberPatterns : paymentPatterns headers.forEach(h => { const match = patterns.find(([re]) => re.test(h)) map[h] = match ? match[1] : 'skip' }) return map } // ─── Step indicators ────────────────────────────────────────────────────────── function Steps({ current }: { current: number }) { const steps = ['Upload File', 'Map Columns', 'Preview', 'Import'] return (
{steps.map((label, i) => (
{i < current ? : i + 1}
{label}
{i < 3 && —}
))}
) } // ─── Main component ─────────────────────────────────────────────────────────── interface Props { spaceId: string role: string } type ImportResult = { success: number; failed: number; errors: string[] } export default function ImportClient({ spaceId, role }: Props) { const [mode, setMode] = useState('members') const [step, setStep] = useState(0) const [file, setFile] = useState(null) const [headers, setHeaders] = useState([]) const [rows, setRows] = useState([]) const [columnMap, setColumnMap] = useState>({}) const [importing, setImporting] = useState(false) const [result, setResult] = useState(null) const inputRef = useRef(null) const platform = mode === 'payments' ? 'csv' : undefined function handleFile(f: File) { const reader = new FileReader() reader.onload = e => { const text = e.target?.result as string const { headers: h, rows: r } = parseCSV(text) if (h.length === 0) { toast.error('Could not parse file — check that it is a valid CSV'); return } setFile(f) setHeaders(h) setRows(r) setColumnMap(autoMap(h, mode)) setStep(1) } reader.readAsText(f) } function handleDrop(e: React.DragEvent) { e.preventDefault() const f = e.dataTransfer.files[0] if (f) handleFile(f) } const previewRows = useMemo(() => { return rows.slice(0, 5).map(row => { const obj: Record = {} headers.forEach((h, i) => { if (columnMap[h] && columnMap[h] !== 'skip') { obj[columnMap[h]] = row[i] ?? '' } }) return obj }) }, [rows, headers, columnMap]) const requiredFields = mode === 'members' ? MEMBER_FIELDS.filter(f => f.required).map(f => f.key) : PAYMENT_FIELDS.filter(f => f.required).map(f => f.key) const mappedFields = Object.values(columnMap).filter(v => v !== 'skip') const missingRequired = requiredFields.filter(r => !mappedFields.includes(r)) async function runImport() { setImporting(true) const errors: string[] = [] let success = 0 if (mode === 'payments') { const paymentRows = rows.map((row, i) => { const obj: Record = {} headers.forEach((h, j) => { if (columnMap[h] && columnMap[h] !== 'skip') obj[columnMap[h]] = row[j] ?? '' }) const amount = parseFloat((obj.amount ?? '').replace(/[$,\s]/g, '')) if (isNaN(amount) || !obj.from_identifier || !obj.transaction_date) { errors.push(`Row ${i + 2}: missing required fields`) return null } return { platform: (obj.platform || 'csv') as any, amount, from_identifier: obj.from_identifier, from_note: obj.from_note, transaction_date: new Date(obj.transaction_date).toISOString(), } }).filter(Boolean) as any[] const result = await importPaymentsCsv(paymentRows) if ('error' in result && result.error) { toast.error(result.error) setImporting(false) return } success = (result as any).count ?? paymentRows.length } else { // Members import — call supabase directly since there's no existing action for bulk member import const { createClient } = await import('@/lib/supabase/client') const supabase = createClient() for (let i = 0; i < rows.length; i++) { const row = rows[i] const obj: Record = {} headers.forEach((h, j) => { if (columnMap[h] && columnMap[h] !== 'skip') obj[columnMap[h]] = row[j] ?? '' }) if (!obj.display_name || !obj.email) { errors.push(`Row ${i + 2}: missing name or email`) continue } const joined_at = obj.joined_at ? new Date(obj.joined_at).toISOString() : new Date().toISOString() const last_paid_at = obj.last_paid_at ? new Date(obj.last_paid_at).toISOString() : null const has_card_access = /yes|true|1|y/i.test(obj.has_card_access ?? '') // Map tier value to valid enum or default to 'basic' const tierMap: Record = { plus: 'plus', premium: 'plus', full: 'plus', basic: 'basic', member: 'basic', standard: 'basic', associate: 'associate', visitor: 'associate', guest: 'associate', } const tier = tierMap[(obj.tier || '').toLowerCase()] || 'basic' const { error } = await supabase.from('space_members').insert({ space_id: spaceId, user_id: crypto.randomUUID(), display_name: obj.display_name, email: obj.email, phone: obj.phone || null, handle: obj.handle || null, tier, role: 'member', status: 'current', approved: true, joined_at, last_paid_at, has_card_access, }) if (error) errors.push(`Row ${i + 2} (${obj.email}): ${error.message}`) else success++ } } setImporting(false) setResult({ success, failed: errors.length, errors }) setStep(3) if (success > 0) toast.success(`Imported ${success} ${mode === 'members' ? 'members' : 'payments'}`) if (errors.length > 0) toast.error(`${errors.length} rows failed`) } function reset() { setStep(0) setFile(null) setHeaders([]) setRows([]) setColumnMap({}) setResult(null) } return (
{/* Mode selector */}

Import type:

{(['members', 'payments'] as ImportMode[]).map(m => ( ))}
{/* ─── Step 0: Upload ─── */} {step === 0 && (
e.preventDefault()} onClick={() => inputRef.current?.click()} className="border-2 border-dashed border-border rounded-lg p-12 text-center hover:border-primary/50 transition cursor-pointer" >

Drop your CSV file here

or click to browse · CSV format

{ const f = e.target.files?.[0]; if (f) handleFile(f) }} />

Expected columns for {mode} import

{mode === 'members' ? 'Required: display_name/Full Name, email. Optional: phone, handle, tier, joined_at, last_paid_at, has_card_access' : 'Required: amount, from_identifier/name, transaction_date. Optional: platform, note/memo'}

)} {/* ─── Step 1: Map Columns ─── */} {step === 1 && (

Map Columns to App Fields

{file?.name} · {rows.length} rows

{headers.map(header => (
{header} {rows[0] && ( — e.g. {rows[0][headers.indexOf(header)] || '(empty)'} )}
))}
{missingRequired.length > 0 && (

Required fields not yet mapped: {missingRequired.join(', ')}

)}
)} {/* ─── Step 2: Preview ─── */} {step === 2 && (

Preview (first 5 of {rows.length} rows)

{Object.keys(previewRows[0] ?? {}).map(k => ( ))} {previewRows.map((row, i) => ( {Object.values(row).map((v, j) => ( ))} ))}
{k}
{v || —}

{rows.length} rows ready to import

into {mode === 'members' ? 'Space Members' : 'Payments'} table

)} {/* ─── Step 3: Results ─── */} {step === 3 && result && (
{result.failed === 0 ? : }

{result.success} imported successfully{result.failed > 0 ? `, ${result.failed} failed` : ''}

{result.errors.length > 0 && (
    {result.errors.slice(0, 10).map((e, i) => (
  • {e}
  • ))} {result.errors.length > 10 && (
  • ...and {result.errors.length - 10} more
  • )}
)}
)}
) }