diff --git a/frontend/docs/ADDRESS_BOOK.md b/frontend/docs/ADDRESS_BOOK.md new file mode 100644 index 00000000..d89cc3cc --- /dev/null +++ b/frontend/docs/ADDRESS_BOOK.md @@ -0,0 +1,202 @@ +# Address Book Feature + +## Overview + +The Address Book feature allows users to save and manage frequently used Stacks addresses with custom labels and notes. This improves the user experience by reducing the need to manually enter or copy-paste addresses for recurring transactions. + +## Features + +### Core Functionality + +- **Add Addresses**: Save Stacks addresses with custom labels and optional notes +- **Edit Addresses**: Update labels, addresses, or notes for existing entries +- **Delete Addresses**: Remove addresses from the address book +- **Search**: Filter addresses by label, address, or notes +- **Import/Export**: Backup and restore address book data via JSON + +### User Interface + +- **Full Mode**: Complete address book management interface with all features +- **Compact Mode**: Streamlined view for quick address selection (used in SendTip component) + +## Usage + +### Adding an Address + +1. Navigate to the Address Book page +2. Click "Add Address" button +3. Fill in the form: + - **Label** (required): A friendly name for the address (max 50 characters) + - **Address** (required): A valid Stacks mainnet or testnet address + - **Notes** (optional): Additional information about the address (max 200 characters) +4. Click "Add" to save + +### Editing an Address + +1. Find the address entry in the list +2. Click the "Edit" button +3. Modify the fields as needed +4. Click "Update" to save changes + +### Deleting an Address + +1. Find the address entry in the list +2. Click the "Delete" button +3. Confirm the deletion in the dialog + +### Searching Addresses + +1. Use the search input at the top of the address book +2. Type any part of the label, address, or notes +3. The list will filter in real-time +4. Click the "X" button to clear the search + +### Importing Addresses + +1. Click "Import/Export" button +2. Switch to the "Import" tab +3. Either: + - Click "Choose File" to select a JSON file + - Paste JSON data directly into the textarea +4. Click "Import" +5. Duplicate addresses will be skipped automatically + +### Exporting Addresses + +1. Click "Import/Export" button +2. Stay on the "Export" tab +3. Either: + - Click "Download as JSON" to save a file + - Click "Copy to Clipboard" to copy the data + +### Using Address Book in SendTip + +1. Navigate to the Send Tip page +2. The address book appears in compact mode below the recipient field +3. Click on any saved address to auto-fill the recipient field +4. The label will also be displayed for reference + +## Data Storage + +- Address book data is stored in browser localStorage +- Storage key: `tipstream_address_book` +- Data persists across browser sessions +- Clearing browser data will remove saved addresses + +## Validation Rules + +### Label +- Required field +- Maximum 50 characters +- Can contain any characters + +### Address +- Required field +- Must be a valid Stacks address format +- Supports both mainnet (SP) and testnet (ST) addresses +- Must be 41 characters long +- Cannot be a duplicate of an existing address in the book + +### Notes +- Optional field +- Maximum 200 characters +- Can contain any characters + +## Analytics Tracking + +The following events are tracked for analytics: + +- `addressBookAdded`: When a new address is added +- `addressBookUpdated`: When an address is edited +- `addressBookDeleted`: When an address is removed +- `addressBookImported`: When addresses are imported (includes count) +- `addressBookExported`: When addresses are exported +- `addressBookSearched`: When the search field is used +- `addressBookSelected`: When an address is selected in compact mode + +## Technical Implementation + +### Components + +- **AddressBook**: Main component with full functionality +- **AddressBookEntry**: Individual address entry display +- **AddressBookForm**: Form for adding/editing addresses +- **AddressBookSearch**: Search input with clear button +- **AddressBookImportExport**: Import/export interface + +### Storage Layer + +- **AddressBookEntry**: Class representing a single address entry +- **AddressBook**: Singleton class managing all address book operations +- **addressBook**: Global instance for application-wide access + +### Validation + +- **validateAddressBookEntry**: Validates label, address, and notes +- **formatAddress**: Formats addresses for display (truncation) + +## Testing + +Comprehensive test coverage includes: + +- **Unit Tests**: Storage layer and validation logic +- **Component Tests**: UI interactions and user flows +- **Integration Tests**: End-to-end address book workflows + +Run tests with: +```bash +npm test addressBook +npm test addressBookValidation +npm test AddressBook +``` + +## Accessibility + +- All form inputs have proper labels +- Buttons have descriptive text and ARIA labels +- Search clear button has aria-label +- Keyboard navigation supported throughout +- Focus management for form interactions + +## Future Enhancements + +Potential improvements for future versions: + +- Address book categories/tags +- Bulk operations (delete multiple, export selected) +- Address verification against blockchain +- Sync across devices (requires backend) +- Address book sharing between users +- Recent addresses auto-save +- Address nicknames with emoji support +- Integration with Stacks Name Service (BNS) + +## Troubleshooting + +### Addresses not persisting +- Check if localStorage is enabled in browser +- Verify browser storage quota is not exceeded +- Check browser console for errors + +### Import failing +- Ensure JSON format is valid +- Verify all required fields (label, address) are present +- Check that addresses are valid Stacks addresses + +### Search not working +- Clear search and try again +- Check if addresses actually contain the search term +- Verify JavaScript is enabled + +## Related Files + +- `frontend/src/components/AddressBook.jsx` +- `frontend/src/components/AddressBookEntry.jsx` +- `frontend/src/components/AddressBookForm.jsx` +- `frontend/src/components/AddressBookSearch.jsx` +- `frontend/src/components/AddressBookImportExport.jsx` +- `frontend/src/lib/addressBook.js` +- `frontend/src/lib/addressValidation.js` +- `frontend/src/test/addressBook.test.js` +- `frontend/src/test/addressBookValidation.test.js` +- `frontend/src/test/AddressBook.test.jsx` diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7ff40aa2..a31fab68 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,11 +19,11 @@ import { useSessionSync } from './hooks/useSessionSync'; import { useDemoMode } from './context/DemoContext'; import { ROUTE_SEND, ROUTE_BATCH, ROUTE_TOKEN_TIP, ROUTE_SCHEDULE, ROUTE_SCHEDULED_TIPS, ROUTE_FEED, - ROUTE_LEADERBOARD, ROUTE_ACTIVITY, ROUTE_PROFILE, + ROUTE_LEADERBOARD, ROUTE_ACTIVITY, ROUTE_PROFILE, ROUTE_ADDRESS_BOOK, ROUTE_BLOCK, ROUTE_STATS, ROUTE_ADMIN, ROUTE_TELEMETRY, DEFAULT_AUTHENTICATED_ROUTE, ROUTE_META, } from './config/routes'; -import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock } from 'lucide-react'; +import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock, BookUser } from 'lucide-react'; import { activateDemo, deactivateDemo } from './lib/demo-utils'; const AnimatedHero = lazy(() => import('./components/ui/animated-hero').then(m => ({ default: m.AnimatedHero }))); @@ -36,6 +36,7 @@ const PlatformStats = lazy(() => import('./components/PlatformStats')); const RecentTips = lazy(() => import('./components/RecentTips')); const Leaderboard = lazy(() => import('./components/Leaderboard')); const ProfileManager = lazy(() => import('./components/ProfileManager')); +const AddressBook = lazy(() => import('./components/AddressBook')); const BlockManager = lazy(() => import('./components/BlockManager')); const BatchTip = lazy(() => import('./components/BatchTip')); const TokenTip = lazy(() => import('./components/TokenTip')); @@ -165,6 +166,7 @@ function App() { { path: ROUTE_LEADERBOARD, label: 'Leaderboard', icon: Trophy }, { path: ROUTE_ACTIVITY, label: 'My Activity', icon: User }, { path: ROUTE_PROFILE, label: 'Profile', icon: UserCircle }, + { path: ROUTE_ADDRESS_BOOK, label: 'Address Book', icon: BookUser }, { path: ROUTE_BLOCK, label: 'Block', icon: ShieldBan }, { path: ROUTE_STATS, label: 'Stats', icon: BarChart3 }, ]; @@ -368,6 +370,18 @@ function App() { ) } /> + + ) : ( + + + + ) + } + /> {/* Admin-only routes */} } /> diff --git a/frontend/src/components/AddressBook.jsx b/frontend/src/components/AddressBook.jsx new file mode 100644 index 00000000..aefae482 --- /dev/null +++ b/frontend/src/components/AddressBook.jsx @@ -0,0 +1,214 @@ +import { useState, useEffect } from 'react'; +import { addressBook } from '../lib/addressBook'; +import { validateAddressBookEntry, formatAddress } from '../lib/addressValidation'; +import { analytics } from '../lib/analytics'; +import AddressBookEntry from './AddressBookEntry'; +import AddressBookForm from './AddressBookForm'; +import AddressBookSearch from './AddressBookSearch'; +import AddressBookImportExport from './AddressBookImportExport'; + +export default function AddressBook({ onSelectAddress, compact = false }) { + const [entries, setEntries] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [showForm, setShowForm] = useState(false); + const [editingEntry, setEditingEntry] = useState(null); + const [showImportExport, setShowImportExport] = useState(false); + + useEffect(() => { + loadEntries(); + }, []); + + const loadEntries = () => { + setEntries(addressBook.getAll()); + }; + + const handleAdd = (label, address, notes) => { + try { + addressBook.add(label, address, notes); + analytics.trackAddressBookAdded(); + loadEntries(); + setShowForm(false); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }; + + const handleUpdate = (id, updates) => { + try { + addressBook.update(id, updates); + analytics.trackAddressBookUpdated(); + loadEntries(); + setEditingEntry(null); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }; + + const handleDelete = (id) => { + if (window.confirm('Are you sure you want to delete this address?')) { + try { + addressBook.delete(id); + analytics.trackAddressBookDeleted(); + loadEntries(); + } catch (error) { + alert(`Failed to delete: ${error.message}`); + } + } + }; + + const handleSelect = (entry) => { + if (onSelectAddress) { + analytics.trackAddressBookSelected(); + onSelectAddress(entry.address, entry.label); + } + }; + + const handleEdit = (entry) => { + setEditingEntry(entry); + setShowForm(true); + }; + + const handleCancelEdit = () => { + setEditingEntry(null); + setShowForm(false); + }; + + const handleImport = (result) => { + analytics.trackAddressBookImported(result.imported.length); + loadEntries(); + setShowImportExport(false); + alert(`Imported ${result.imported.length} addresses. Skipped ${result.skipped.length} duplicates.`); + }; + + const filteredEntries = searchQuery + ? addressBook.search(searchQuery) + : entries; + + useEffect(() => { + if (searchQuery) { + analytics.trackAddressBookSearched(); + } + }, [searchQuery]); + + if (compact) { + return ( +
+ +
+ {filteredEntries.length === 0 ? ( +

+ {searchQuery ? 'No addresses found' : 'No saved addresses'} +

+ ) : ( + filteredEntries.map(entry => ( + + )) + )} +
+
+ ); + } + + return ( +
+
+

Address Book

+
+ + +
+
+ + {showForm && ( +
+ +
+ )} + + {showImportExport && ( +
+ setShowImportExport(false)} + /> +
+ )} + +
+ +
+ +
+ {filteredEntries.length === 0 ? ( +
+ {searchQuery ? ( +

No addresses found matching "{searchQuery}"

+ ) : ( +
+

Your address book is empty

+

Add frequently used addresses for quick access

+
+ )} +
+ ) : ( + filteredEntries.map(entry => ( +
+ handleSelect(entry) : null} + onEdit={() => handleEdit(entry)} + onDelete={() => handleDelete(entry.id)} + /> +
+ )) + )} +
+ + {entries.length > 0 && ( +
+

+ {entries.length} address{entries.length !== 1 ? 'es' : ''} saved +

+
+ )} +
+ ); +} diff --git a/frontend/src/components/AddressBookEntry.jsx b/frontend/src/components/AddressBookEntry.jsx new file mode 100644 index 00000000..056e050c --- /dev/null +++ b/frontend/src/components/AddressBookEntry.jsx @@ -0,0 +1,83 @@ +import { formatAddress } from '../lib/addressValidation'; +import { Copy, Edit2, Trash2, Check } from 'lucide-react'; + +export default function AddressBookEntry({ entry, onSelect, onEdit, onDelete }) { + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(entry.address); + } catch (error) { + console.error('Failed to copy address:', error); + const textarea = document.createElement('textarea'); + textarea.value = entry.address; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand('copy'); + } catch (fallbackError) { + console.error('Fallback copy failed:', fallbackError); + } + document.body.removeChild(textarea); + } + }; + + const formattedDate = new Date(entry.updatedAt).toLocaleDateString(); + + return ( +
+
+
+

{entry.label}

+

+ {formatAddress(entry.address, 10)} +

+ {entry.notes && ( +

{entry.notes}

+ )} +

Updated: {formattedDate}

+
+
+ {onSelect && ( + + )} + + + +
+
+
+ ); +} diff --git a/frontend/src/components/AddressBookForm.jsx b/frontend/src/components/AddressBookForm.jsx new file mode 100644 index 00000000..3197a8b4 --- /dev/null +++ b/frontend/src/components/AddressBookForm.jsx @@ -0,0 +1,146 @@ +import { useState, useEffect } from 'react'; +import { validateAddressBookEntry } from '../lib/addressValidation'; + +export default function AddressBookForm({ entry, onSubmit, onCancel }) { + const [label, setLabel] = useState(''); + const [address, setAddress] = useState(''); + const [notes, setNotes] = useState(''); + const [errors, setErrors] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (entry) { + setLabel(entry.label); + setAddress(entry.address); + setNotes(entry.notes || ''); + } + }, [entry]); + + const handleSubmit = async (e) => { + e.preventDefault(); + setErrors({}); + + const validation = validateAddressBookEntry(label.trim(), address.trim(), notes.trim()); + if (!validation.isValid) { + setErrors(validation.errors); + return; + } + + setIsSubmitting(true); + const result = entry + ? await onSubmit(entry.id, { + label: label.trim(), + address: address.trim(), + notes: notes.trim(), + }) + : await onSubmit(label.trim(), address.trim(), notes.trim()); + + setIsSubmitting(false); + + if (result.success) { + setLabel(''); + setAddress(''); + setNotes(''); + setErrors({}); + } else { + setErrors({ submit: result.error }); + } + }; + + return ( +
+

+ {entry ? 'Edit Address' : 'Add New Address'} +

+ +
+
+ + setLabel(e.target.value)} + placeholder="e.g., Alice, Bob, Main Wallet" + maxLength={50} + disabled={isSubmitting} + className={`w-full px-4 py-2 rounded-lg border ${ + errors.label + ? 'border-red-500 dark:border-red-500' + : 'border-gray-300 dark:border-gray-600' + } bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-amber-500 disabled:opacity-50`} + /> + {errors.label && {errors.label}} +
+ +
+ + setAddress(e.target.value)} + placeholder="SP..." + disabled={isSubmitting} + className={`w-full px-4 py-2 rounded-lg border ${ + errors.address + ? 'border-red-500 dark:border-red-500' + : 'border-gray-300 dark:border-gray-600' + } bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-amber-500 disabled:opacity-50 font-mono text-sm`} + /> + {errors.address && {errors.address}} +
+ +
+ +