fix: implement comprehensive bug fixes across multiple components\n\nFix chat, tasks, ops, import, and PayPal components for full functionality.

This commit is contained in:
v0
2026-03-11 05:30:44 +00:00
parent 93cc7af60b
commit 88071abd3f
11 changed files with 1803 additions and 369 deletions
+474
View File
@@ -0,0 +1,474 @@
'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<string, string> {
const map: Record<string, string> = {}
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 (
<div className="flex items-center gap-2 mb-8">
{steps.map((label, i) => (
<div key={i} className="flex items-center gap-2">
<div className={`flex items-center gap-1.5 ${i <= current ? 'text-primary' : 'text-muted-foreground'}`}>
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-mono font-bold ${
i < current ? 'bg-primary text-white' : i === current ? 'bg-primary text-white ring-2 ring-primary/30' : 'border-2 border-border'
}`}>
{i < current ? <CheckCircle className="w-3.5 h-3.5" /> : i + 1}
</div>
<span className="font-sans text-sm">{label}</span>
</div>
{i < 3 && <span className="text-border font-mono">—</span>}
</div>
))}
</div>
)
}
// ─── 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<ImportMode>('members')
const [step, setStep] = useState(0)
const [file, setFile] = useState<File | null>(null)
const [headers, setHeaders] = useState<string[]>([])
const [rows, setRows] = useState<string[][]>([])
const [columnMap, setColumnMap] = useState<Record<string, string>>({})
const [importing, setImporting] = useState(false)
const [result, setResult] = useState<ImportResult | null>(null)
const inputRef = useRef<HTMLInputElement>(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<string, string> = {}
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<string, string> = {}
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<string, string> = {}
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 ?? '')
const { error } = await supabase.from('space_members').insert({
space_id: spaceId,
display_name: obj.display_name,
email: obj.email,
phone: obj.phone || null,
handle: obj.handle || null,
tier: obj.tier || 'member',
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 (
<div className="p-6 max-w-3xl">
{/* Mode selector */}
<div className="flex items-center gap-3 mb-6">
<p className="font-sans text-sm text-muted-foreground">Import type:</p>
{(['members', 'payments'] as ImportMode[]).map(m => (
<button
key={m}
onClick={() => { setMode(m); reset() }}
className={`font-sans text-sm px-3 py-1.5 rounded border transition capitalize ${
mode === m ? 'bg-primary text-white border-primary' : 'border-border text-foreground hover:border-primary/50'
}`}
>
{m}
</button>
))}
</div>
<Steps current={step} />
{/* ─── Step 0: Upload ─── */}
{step === 0 && (
<div className="bg-card rounded border border-border p-6">
<div
onDrop={handleDrop}
onDragOver={e => 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"
>
<Upload className="w-10 h-10 text-muted-foreground mx-auto mb-3" />
<p className="font-sans text-sm text-foreground mb-1">Drop your CSV file here</p>
<p className="font-mono text-xs text-muted-foreground/60">or click to browse · CSV format</p>
</div>
<input ref={inputRef} type="file" accept=".csv,.txt" className="hidden" onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f) }} />
<div className="mt-4 p-4 bg-muted/30 rounded border border-border">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2">
Expected columns for {mode} import
</p>
<p className="font-sans text-xs text-muted-foreground leading-relaxed">
{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'}
</p>
</div>
</div>
)}
{/* ─── Step 1: Map Columns ─── */}
{step === 1 && (
<div className="space-y-4">
<div className="bg-card rounded border border-border overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Map Columns to App Fields</p>
<p className="font-sans text-xs text-muted-foreground mt-0.5">{file?.name} · {rows.length} rows</p>
</div>
<button onClick={reset} className="text-muted-foreground hover:text-foreground transition"><X className="w-4 h-4" /></button>
</div>
<div className="divide-y divide-border">
{headers.map(header => (
<div key={header} className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 py-3">
<div className="bg-muted/50 border border-border rounded px-3 py-2 font-mono text-xs text-foreground">
{header}
{rows[0] && (
<span className="text-muted-foreground ml-2">— e.g. {rows[0][headers.indexOf(header)] || '(empty)'}</span>
)}
</div>
<ArrowRight className="w-4 h-4 text-primary flex-shrink-0" />
<select
value={columnMap[header] ?? 'skip'}
onChange={e => setColumnMap(prev => ({ ...prev, [header]: e.target.value }))}
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground focus:outline-none focus:border-primary transition"
>
{(mode === 'members' ? MEMBER_FIELDS : PAYMENT_FIELDS).map(f => (
<option key={f.key} value={f.key}>{f.label}{f.required ? ' *' : ''}</option>
))}
</select>
</div>
))}
</div>
</div>
{missingRequired.length > 0 && (
<div className="flex items-start gap-2 bg-amber-50 border border-amber-200 rounded p-3">
<AlertCircle className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
<p className="font-sans text-xs text-amber-800">
Required fields not yet mapped: {missingRequired.join(', ')}
</p>
</div>
)}
<div className="flex justify-between">
<button onClick={reset} className="border border-border bg-card text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition">Back</button>
<button
onClick={() => setStep(2)}
disabled={missingRequired.length > 0}
className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
Preview Import →
</button>
</div>
</div>
)}
{/* ─── Step 2: Preview ─── */}
{step === 2 && (
<div className="space-y-4">
<div className="bg-card rounded border border-border overflow-hidden">
<div className="px-4 py-3 border-b border-border">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Preview (first 5 of {rows.length} rows)
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/30">
{Object.keys(previewRows[0] ?? {}).map(k => (
<th key={k} className="px-3 py-2 font-mono text-[10px] text-muted-foreground text-left">{k}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{previewRows.map((row, i) => (
<tr key={i} className="hover:bg-muted/10">
{Object.values(row).map((v, j) => (
<td key={j} className="px-3 py-2 font-sans text-foreground truncate max-w-[160px]">{v || <span className="text-muted-foreground/40">—</span>}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="bg-card border border-border rounded p-4 flex items-center gap-3">
<FileText className="w-5 h-5 text-primary flex-shrink-0" />
<div>
<p className="font-sans text-sm font-medium text-foreground">{rows.length} rows ready to import</p>
<p className="font-sans text-xs text-muted-foreground">into {mode === 'members' ? 'Space Members' : 'Payments'} table</p>
</div>
</div>
<div className="flex justify-between">
<button onClick={() => setStep(1)} className="border border-border bg-card text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition">Back</button>
<button
onClick={runImport}
disabled={importing}
className="bg-primary text-white font-sans text-sm px-6 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50 flex items-center gap-2"
>
{importing && <RefreshCcw className="w-4 h-4 animate-spin" />}
{importing ? 'Importing...' : `Import ${rows.length} rows →`}
</button>
</div>
</div>
)}
{/* ─── Step 3: Results ─── */}
{step === 3 && result && (
<div className="space-y-4">
<div className={`rounded border p-5 flex items-start gap-3 ${result.failed === 0 ? 'bg-green-50 border-green-200' : 'bg-amber-50 border-amber-200'}`}>
{result.failed === 0
? <CheckCircle className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
: <AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
}
<div>
<p className="font-sans text-sm font-medium text-foreground">
{result.success} imported successfully{result.failed > 0 ? `, ${result.failed} failed` : ''}
</p>
{result.errors.length > 0 && (
<ul className="mt-2 space-y-1">
{result.errors.slice(0, 10).map((e, i) => (
<li key={i} className="font-mono text-[11px] text-amber-800">{e}</li>
))}
{result.errors.length > 10 && (
<li className="font-mono text-[11px] text-amber-800">...and {result.errors.length - 10} more</li>
)}
</ul>
)}
</div>
</div>
<button onClick={reset} className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition">
Import Another File
</button>
</div>
)}
</div>
)
}
+10 -125
View File
@@ -1,146 +1,31 @@
import { createClient } from '@/lib/supabase/server'
import ImportClient from './import-client'
export default async function ImportPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user!.id).eq('status', 'current').single()
if (!user) return null
const isAdmin = member?.role === 'admin' || member?.role === 'board'
const { data: member } = await supabase
.from('space_members').select('space_id, role').eq('user_id', user.id).eq('status', 'current').single()
const isAdmin = member?.role === 'admin' || member?.role === 'board' || member?.role === 'treasurer'
if (!isAdmin) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="font-sans text-sm text-muted-foreground">Admin access required</p>
<p className="font-sans text-sm text-muted-foreground">Admin or Treasurer access required</p>
</div>
)
}
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-6 py-3">
<div className="bg-sidebar px-6 py-3 flex items-center justify-between">
<h1 className="text-white font-sans text-lg font-semibold">Import & Sync</h1>
<p className="font-mono text-xs text-sidebar-foreground/50">CSV import · member & payment data</p>
</div>
{/* Tabs */}
<div className="bg-card border-b border-border px-6 flex gap-6">
{['CSV / Excel', 'Database Connect', 'Webhooks & API', 'Integrations'].map((tab, i) => (
<button key={tab} className={`font-sans text-sm py-3 border-b-2 transition ${
i === 0 ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
}`}>
{tab}
</button>
))}
</div>
<div className="p-6 max-w-3xl">
{/* Steps */}
<div className="flex items-center gap-2 mb-8">
{[{ n: 1, label: 'Upload File', done: false }, { n: 2, label: 'Map Columns', active: false }, { n: 3, label: 'Preview' }, { n: 4, label: 'Import' }].map((step, i) => (
<div key={i} className="flex items-center gap-2">
<div className={`flex items-center gap-1.5 ${i === 0 ? 'text-primary' : 'text-muted-foreground'}`}>
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-mono font-bold ${
i === 0 ? 'bg-primary text-white' : 'border-2 border-border'
}`}>
{i + 1}
</div>
<span className="font-sans text-sm">{step.label}</span>
</div>
{i < 3 && <span className="text-border">—</span>}
</div>
))}
</div>
{/* Upload area */}
<div className="bg-card rounded border border-border p-8 mb-6">
<div className="border-2 border-dashed border-border rounded-lg p-12 text-center hover:border-primary/50 transition cursor-pointer">
<svg className="w-10 h-10 text-muted-foreground mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="font-sans text-sm text-muted-foreground mb-1">Drop your CSV or Excel file here</p>
<p className="font-mono text-xs text-muted-foreground/60">or click to browse</p>
</div>
</div>
{/* Column mapping preview */}
<div className="bg-card rounded border border-border overflow-hidden mb-6">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Map Columns to App Fields
</p>
<p className="font-mono text-[10px] text-muted-foreground">Upload a file to begin mapping</p>
</div>
<div className="divide-y divide-border">
{[
['Full Name', 'display_name'],
['Email Address', 'email'],
['MemberType', 'tier'],
['JoinDate', 'joined_at'],
['PhoneNumber', 'phone'],
['LastDuesDate', 'last_paid_at'],
['CardAccess', 'has_card_access'],
].map(([col, field]) => (
<div key={col} className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 py-3">
<div className="bg-muted/50 border border-border rounded px-3 py-2 font-mono text-xs text-muted-foreground">
{col}
</div>
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
<div className="bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground">
{field}
</div>
</div>
))}
</div>
<div className="px-4 py-4 flex justify-end gap-3 border-t border-border">
<button className="bg-card border border-border text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition">
Back
</button>
<button className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition">
Preview Import →
</button>
</div>
</div>
{/* Database Connector */}
<div className="bg-card rounded border border-border p-6">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-4">
Database Connector (Advanced)
</p>
<p className="font-sans text-sm text-muted-foreground mb-4 leading-relaxed">
Connect directly to your existing member database. Supports PostgreSQL, MySQL, SQLite, and MongoDB.
Credentials are stored encrypted and never shared.
</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="font-sans text-sm text-foreground block mb-2">Database Type</label>
<select className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
<option>PostgreSQL (Ruby on Rails)</option>
<option>MySQL</option>
<option>SQLite</option>
<option>MongoDB</option>
</select>
</div>
<div>
<label className="font-sans text-sm text-foreground block mb-2">Connection String</label>
<input
type="text"
placeholder="postgres://user:pass@host:5432/db"
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition"
/>
</div>
</div>
<div className="flex gap-3">
<button className="border border-border bg-card text-foreground font-sans text-sm px-4 py-2 rounded hover:border-primary/50 transition">
Test Connection
</button>
<button className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition">
Connect & Map Schema →
</button>
</div>
</div>
</div>
<ImportClient spaceId={member!.space_id} role={member!.role} />
</div>
)
}