| import React, { useState, useEffect } from 'react'; |
| import { useToast } from '../components/ui/index'; |
|
|
| interface Contact { name: string; address: string; notes?: string; txCount: number; } |
|
|
| export default function ContactsPage() { |
| const [contacts, setContacts] = useState<Contact[]>([]); |
| const [name, setName] = useState(''); const [addr, setAddr] = useState(''); const [notes, setNotes] = useState(''); |
| const [searchQ, setSearchQ] = useState(''); const [resolved, setResolved] = useState<any>(null); |
| const [err, setErr] = useState(''); |
| const { addToast } = useToast(); |
|
|
| useEffect(() => { load(); }, []); |
| const load = async () => { if (window.solvox) { const c = await window.solvox.ai.getContacts(); setContacts(c || []); } }; |
|
|
| const add = async () => { |
| if (!name.trim() || !addr.trim()) return setErr('Name and address required'); |
| if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(addr.trim())) return setErr('Invalid Solana address'); |
| if (window.solvox) { |
| const r = await window.solvox.ai.addContact({ name: name.trim(), address: addr.trim(), notes: notes.trim(), txCount: 0 }); |
| if (r.success) { setName(''); setAddr(''); setNotes(''); setErr(''); load(); addToast({ type: 'success', title: `${name} added to contacts` }); } |
| else setErr(r.error || 'Failed'); |
| } |
| }; |
|
|
| const resolve = async () => { |
| if (!searchQ.trim()) return; |
| if (window.solvox) { |
| const r = await window.solvox.ai.resolveContact(searchQ.trim()); |
| setResolved(r.success ? r.contact : null); |
| } |
| }; |
|
|
| return ( |
| <div className="max-w-3xl mx-auto px-8 py-section"> |
| <div className="flex items-center justify-between mb-8"> |
| <div> |
| <h2 className="display-text text-title-lg text-ink">Contacts</h2> |
| <p className="text-body-sm text-body mt-1">Semantic contact book — find anyone by name, description, or nickname</p> |
| </div> |
| <span className="badge-pill-blue badge-pill text-[10px]">AI-POWERED</span> |
| </div> |
| |
| {/* AI Contact Search */} |
| <div className="card mb-6"> |
| <div className="text-caption-strong text-muted uppercase tracking-wider mb-3">Semantic search</div> |
| <p className="text-caption text-muted mb-3">Type a name, nickname, or description. Embeddings find the closest match — no exact spelling needed.</p> |
| <div className="flex gap-2"> |
| <input value={searchQ} onChange={e => setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && resolve()} |
| placeholder='Try "alice", "my friend who works at…", "the devnet test wallet"' className="search-pill flex-1 text-body-sm" /> |
| <button onClick={resolve} className="btn-primary text-body-sm py-2 px-5">Resolve</button> |
| </div> |
| <div className="text-caption text-muted mt-2 flex items-center gap-1.5"> |
| <div className="w-1 h-1 rounded-full bg-primary" /> |
| Powered by @qvac/embed-llamacpp — cosine similarity over embedded contacts |
| </div> |
| |
| {resolved && ( |
| <div className="mt-4 bg-surface-soft rounded-xl p-4 page-enter"> |
| <div className="text-caption-strong text-primary uppercase tracking-wider mb-2">Match found</div> |
| <div className="flex items-center justify-between"> |
| <div> |
| <div className="text-title-sm text-ink">{resolved.name}</div> |
| <div className="text-caption font-mono text-muted mt-0.5">{resolved.address}</div> |
| </div> |
| <div className="text-right"> |
| <span className="badge-pill-green badge-pill text-[10px]">{(resolved.confidence * 100).toFixed(0)}% MATCH</span> |
| <button onClick={() => { navigator.clipboard.writeText(resolved.address); addToast({ type: 'info', title: 'Address copied' }); }} |
| className="btn-text text-body-sm ml-2">Copy</button> |
| </div> |
| </div> |
| </div> |
| )} |
| {resolved === null && searchQ && ( |
| <div className="mt-3 text-body-sm text-muted text-center">No matching contact found</div> |
| )} |
| </div> |
| |
| {/* Add Contact */} |
| <div className="card mb-6"> |
| <div className="text-caption-strong text-muted uppercase tracking-wider mb-3">Add contact</div> |
| <div className="space-y-3"> |
| <div className="grid grid-cols-2 gap-3"> |
| <input value={name} onChange={e => setName(e.target.value)} placeholder="Name (e.g. Alice)" className="input-field text-body-sm" /> |
| <input value={addr} onChange={e => setAddr(e.target.value)} placeholder="Solana address" className="input-field text-body-sm font-mono" /> |
| </div> |
| <input value={notes} onChange={e => setNotes(e.target.value)} placeholder="Notes (optional — helps AI match)" className="input-field text-body-sm" /> |
| {err && <div className="text-body-sm text-semantic-down">{err}</div>} |
| <button onClick={add} className="btn-primary text-body-sm">Add contact</button> |
| </div> |
| <div className="text-caption text-muted mt-3"> |
| Contact names, addresses, and notes are embedded locally for semantic resolution. Say "send to Alice" and the AI resolves the address. |
| </div> |
| </div> |
| |
| {/* Contact List */} |
| <div className="card" style={{ padding: 0 }}> |
| <div className="px-xl pt-xl pb-3"> |
| <div className="text-caption-strong text-muted uppercase tracking-wider">All contacts · {contacts.length}</div> |
| </div> |
| {contacts.length === 0 ? ( |
| <div className="px-xl pb-xl text-center py-8"> |
| <div className="text-title-md text-ink mb-1">No contacts yet</div> |
| <div className="text-body-sm text-muted">Add your first contact above. The AI will embed it for semantic search.</div> |
| </div> |
| ) : ( |
| <div> |
| {contacts.map((c, i) => ( |
| <div key={i} className="asset-row px-5"> |
| <div className="asset-icon mr-3 text-sm font-bold text-primary">{c.name.charAt(0).toUpperCase()}</div> |
| <div className="flex-1 min-w-0"> |
| <div className="text-title-sm text-ink">{c.name}</div> |
| <div className="text-caption font-mono text-muted">{c.address.slice(0, 12)}…{c.address.slice(-6)}</div> |
| {c.notes && <div className="text-caption text-muted-soft">{c.notes}</div>} |
| </div> |
| <span className="badge-pill text-[10px]">{c.txCount} TX</span> |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|