- {t('dashboard.annualYield')}
+ {t('dashboard.annualYield')}
{formatPercentage(sampleData.roi)}
diff --git a/src/components/TransactionCard.tsx b/src/components/TransactionCard.tsx
index bb2e563a..562cb683 100644
--- a/src/components/TransactionCard.tsx
+++ b/src/components/TransactionCard.tsx
@@ -105,9 +105,20 @@ export const TransactionCard: React.FC = ({
Transaction Hash:
-
- {transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
-
+
+
+ {transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
+
+
navigator.clipboard.writeText(transaction.hash)}
+ className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
+ title="Copy transaction hash"
+ >
+
+
+
+
+
{transaction.description && (
@@ -126,7 +137,14 @@ export const TransactionCard: React.FC
= ({
{transaction.gasUsed && (
-
Gas Used:
+
+ Gas Used:
+
+
+
+
+
+
{transaction.gasUsed}
)}
@@ -135,7 +153,14 @@ export const TransactionCard: React.FC = ({
{(transaction.status === 'pending' || transaction.status === 'processing') && (
-
Confirmations
+
+ Confirmations
+
+
+
+
+
+
{transaction.confirmations}/{transaction.requiredConfirmations || 1}
diff --git a/src/components/TransactionProgress.tsx b/src/components/TransactionProgress.tsx
new file mode 100644
index 00000000..3bffb5a4
--- /dev/null
+++ b/src/components/TransactionProgress.tsx
@@ -0,0 +1,295 @@
+'use client';
+
+import React, { useState, useEffect, useCallback, memo } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { CheckCircle2, Circle, Loader2, AlertCircle, XCircle, Wallet, Broadcast, Clock, Shield } from 'lucide-react';
+import { Progress } from '@/components/ui/progress';
+import { Card, CardContent } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Web3Tooltip } from '@/components/ui/Web3Tooltip';
+
+interface TransactionStep {
+ id: string;
+ label: string;
+ description: string;
+ status: 'pending' | 'in-progress' | 'completed' | 'error';
+ icon: React.ReactNode;
+ error?: string;
+}
+
+interface TransactionProgressProps {
+ isOpen: boolean;
+ onClose: () => void;
+ transactionHash?: string;
+ onComplete?: () => void;
+ onError?: (error: string) => void;
+}
+
+export const TransactionProgress: React.FC
= memo(({
+ isOpen,
+ onClose,
+ transactionHash,
+ onComplete,
+ onError,
+}) => {
+ const [steps, setSteps] = useState([
+ {
+ id: 'sign',
+ label: 'Signing Transaction',
+ description: 'Please sign the transaction in your wallet',
+ status: 'pending',
+ icon: ,
+ },
+ {
+ id: 'broadcast',
+ label: 'Broadcasting to Network',
+ description: 'Transaction is being sent to the blockchain',
+ status: 'pending',
+ icon: ,
+ },
+ {
+ id: 'confirm',
+ label: 'Waiting for Confirmation',
+ description: 'Transaction is being confirmed by the network',
+ status: 'pending',
+ icon: ,
+ },
+ {
+ id: 'complete',
+ label: 'Transaction Confirmed',
+ description: 'Transaction has been successfully completed',
+ status: 'pending',
+ icon: ,
+ },
+ ]);
+
+ const [confirmations, setConfirmations] = useState(0);
+ const [requiredConfirmations] = useState(12);
+ const [currentStep, setCurrentStep] = useState(0);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ // Simulate transaction progress
+ const simulateProgress = async () => {
+ // Step 1: Signing
+ await updateStepStatus('sign', 'in-progress');
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ await updateStepStatus('sign', 'completed');
+ setCurrentStep(1);
+
+ // Step 2: Broadcasting
+ await updateStepStatus('broadcast', 'in-progress');
+ await new Promise(resolve => setTimeout(resolve, 3000));
+ await updateStepStatus('broadcast', 'completed');
+ setCurrentStep(2);
+
+ // Step 3: Confirmations
+ await updateStepStatus('confirm', 'in-progress');
+
+ // Simulate block confirmations
+ for (let i = 1; i <= requiredConfirmations; i++) {
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ setConfirmations(i);
+ }
+
+ await updateStepStatus('confirm', 'completed');
+ setCurrentStep(3);
+
+ // Step 4: Complete
+ await updateStepStatus('complete', 'in-progress');
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ await updateStepStatus('complete', 'completed');
+
+ if (onComplete) {
+ onComplete();
+ }
+ };
+
+ simulateProgress().catch(error => {
+ console.error('Transaction simulation error:', error);
+ if (onError) {
+ onError('Transaction failed. Please try again.');
+ }
+ });
+ }, [isOpen, requiredConfirmations, onComplete, onError]);
+
+ const updateStepStatus = async (stepId: string, status: TransactionStep['status']) => {
+ setSteps(prev => prev.map(step =>
+ step.id === stepId
+ ? { ...step, status }
+ : step
+ ));
+ };
+
+ const getStepIcon = (step: TransactionStep) => {
+ switch (step.status) {
+ case 'completed':
+ return ;
+ case 'in-progress':
+ return ;
+ case 'error':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getProgressPercentage = () => {
+ const completedSteps = steps.filter(step => step.status === 'completed').length;
+ return (completedSteps / steps.length) * 100;
+ };
+
+ const getConfirmationProgress = () => {
+ return (confirmations / requiredConfirmations) * 100;
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+ e.stopPropagation()}
+ >
+
+
+ {/* Header */}
+
+
+
+ Transaction in Progress
+
+ {transactionHash && (
+
+ {transactionHash.slice(0, 10)}...{transactionHash.slice(-8)}
+
+ )}
+
+
+ ร
+
+
+
+ {/* Overall Progress */}
+
+
+ Overall Progress
+
+ {Math.round(getProgressPercentage())}%
+
+
+
+
+
+ {/* Steps */}
+
+ {steps.map((step, index) => (
+
+
+ {getStepIcon(step)}
+
+
+
+ {step.label}
+
+
+ {step.description}
+
+
+ {/* Confirmation Progress */}
+ {step.id === 'confirm' && step.status === 'in-progress' && (
+
+
+
+ Block Confirmations
+
+
+ {confirmations}/{requiredConfirmations}
+
+
+
+
+ )}
+
+ {step.error && (
+
+ {step.error}
+
+ )}
+
+
+ ))}
+
+
+ {/* Footer */}
+
+
+
+ Secured by blockchain
+
+
+ {steps[steps.length - 1].status === 'completed' ? 'Close' : 'Processing...'}
+
+
+
+
+
+
+
+ );
+};
+
+// Hook to use transaction progress
+export const useTransactionProgress = () => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [transactionHash, setTransactionHash] = useState();
+
+ const startTransaction = (hash?: string) => {
+ setTransactionHash(hash);
+ setIsOpen(true);
+ };
+
+ const closeTransaction = () => {
+ setIsOpen(false);
+ };
+
+ return {
+ isOpen,
+ transactionHash,
+ startTransaction,
+ closeTransaction,
+ };
+};
diff --git a/src/components/WalletConnector.tsx b/src/components/WalletConnector.tsx
index 80cab441..1edb1ce9 100644
--- a/src/components/WalletConnector.tsx
+++ b/src/components/WalletConnector.tsx
@@ -79,6 +79,15 @@ export const WalletConnector: React.FC = () => {
{formatAddress(address)}
+ navigator.clipboard.writeText(address)}
+ className="p-1 hover:bg-blue-200 dark:hover:bg-blue-800 rounded transition-colors"
+ title="Copy wallet address"
+ >
+
+
+
+
diff --git a/src/components/__tests__/TransactionProgress.test.tsx b/src/components/__tests__/TransactionProgress.test.tsx
new file mode 100644
index 00000000..f5bdbfe1
--- /dev/null
+++ b/src/components/__tests__/TransactionProgress.test.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { TransactionProgress, useTransactionProgress } from '../TransactionProgress';
+
+describe('TransactionProgress', () => {
+ it('should render when isOpen is true', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Transaction in Progress')).toBeInTheDocument();
+ expect(screen.getByText('Signing Transaction')).toBeInTheDocument();
+ });
+
+ it('should not render when isOpen is false', () => {
+ render(
+
+ );
+
+ expect(screen.queryByText('Transaction in Progress')).not.toBeInTheDocument();
+ });
+
+ it('should display transaction hash when provided', () => {
+ render(
+
+ );
+
+ expect(screen.getByText(/0x1234...cdef/)).toBeInTheDocument();
+ });
+
+ it('should call onClose when close button clicked', async () => {
+ const onClose = jest.fn();
+ render(
+
+ );
+
+ const closeButton = screen.getByText('Close');
+ await userEvent.click(closeButton);
+
+ expect(onClose).toHaveBeenCalled();
+ });
+});
+
+describe('useTransactionProgress', () => {
+ it('should return initial state', () => {
+ const { result } = renderHook(() => useTransactionProgress());
+
+ expect(result.current.isOpen).toBe(false);
+ expect(result.current.transactionHash).toBeUndefined();
+ });
+
+ it('should start transaction', () => {
+ const { result } = renderHook(() => useTransactionProgress());
+ const hash = '0x1234567890abcdef';
+
+ act(() => {
+ result.current.startTransaction(hash);
+ });
+
+ expect(result.current.isOpen).toBe(true);
+ expect(result.current.transactionHash).toBe(hash);
+ });
+
+ it('should close transaction', () => {
+ const { result } = renderHook(() => useTransactionProgress());
+
+ act(() => {
+ result.current.startTransaction('0x1234567890abcdef');
+ });
+
+ act(() => {
+ result.current.closeTransaction();
+ });
+
+ expect(result.current.isOpen).toBe(false);
+ });
+});
diff --git a/src/components/dashboard/DraggablePropertiesList.tsx b/src/components/dashboard/DraggablePropertiesList.tsx
new file mode 100644
index 00000000..2e38b8d7
--- /dev/null
+++ b/src/components/dashboard/DraggablePropertiesList.tsx
@@ -0,0 +1,261 @@
+'use client';
+
+import { motion } from "framer-motion";
+import { useState, useEffect, useCallback, memo } from "react";
+import { GripVertical, RotateCcw } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { PropertyCard } from "./PropertyCard";
+
+interface Property {
+ id: string;
+ name: string;
+ location: string;
+ type: string;
+ value: number;
+ tokens: number;
+ roi: number;
+ monthlyIncome: number;
+ image: string;
+}
+
+const defaultProperties: Property[] = [
+ {
+ id: "1",
+ name: "Manhattan Tower Suite",
+ location: "New York, NY",
+ type: "Commercial",
+ value: 524000,
+ tokens: 1048,
+ roi: 14.2,
+ monthlyIncome: 3280,
+ image: "https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&auto=format&fit=crop&q=80",
+ },
+ {
+ id: "2",
+ name: "Sunset Beach Villa",
+ location: "Miami, FL",
+ type: "Residential",
+ value: 389000,
+ tokens: 778,
+ roi: 11.8,
+ monthlyIncome: 2450,
+ image: "https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800&auto=format&fit=crop&q=80",
+ },
+ {
+ id: "3",
+ name: "Tech Hub Office Complex",
+ location: "San Francisco, CA",
+ type: "Commercial",
+ value: 892000,
+ tokens: 1784,
+ roi: 9.5,
+ monthlyIncome: 5620,
+ image: "https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&auto=format&fit=crop&q=80",
+ },
+ {
+ id: "4",
+ name: "Industrial Logistics Park",
+ location: "Dallas, TX",
+ type: "Industrial",
+ value: 456000,
+ tokens: 912,
+ roi: 8.3,
+ monthlyIncome: 2890,
+ image: "https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&auto=format&fit=crop&q=80",
+ },
+ {
+ id: "5",
+ name: "Downtown Luxury Lofts",
+ location: "Chicago, IL",
+ type: "Residential",
+ value: 312000,
+ tokens: 624,
+ roi: -2.1,
+ monthlyIncome: 1980,
+ image: "https://images.unsplash.com/photo-1545324418-cc1a3fa10c00?w=800&auto=format&fit=crop&q=80",
+ },
+ {
+ id: "6",
+ name: "Mixed-Use Development",
+ location: "Austin, TX",
+ type: "Mixed-Use",
+ value: 274520,
+ tokens: 549,
+ roi: 16.7,
+ monthlyIncome: 2020,
+ image: "https://images.unsplash.com/photo-1512917774080-9991f1c4c750?w=800&auto=format&fit=crop&q=80",
+ },
+];
+
+export const DraggablePropertiesList = memo(() => {
+ const [properties, setProperties] = useState
([]);
+ const [draggedItem, setDraggedItem] = useState(null);
+ const [dragOverIndex, setDragOverIndex] = useState(null);
+
+ // Load properties from localStorage or use default
+ useEffect(() => {
+ const savedOrder = localStorage.getItem('portfolioOrder');
+ if (savedOrder) {
+ try {
+ const savedIds = JSON.parse(savedOrder);
+ const orderedProperties = savedIds.map((id: string) =>
+ defaultProperties.find(p => p.id === id)
+ ).filter(Boolean);
+ setProperties(orderedProperties.length > 0 ? orderedProperties : defaultProperties);
+ } catch (error) {
+ console.error('Error loading portfolio order:', error);
+ setProperties(defaultProperties);
+ }
+ } else {
+ setProperties(defaultProperties);
+ }
+ }, []);
+
+ // Save order to localStorage whenever it changes
+ useEffect(() => {
+ if (properties.length > 0) {
+ localStorage.setItem('portfolioOrder', JSON.stringify(properties.map(p => p.id)));
+ }
+ }, [properties]);
+
+ const handleDragStart = useCallback((e: React.DragEvent, property: Property) => {
+ setDraggedItem(property);
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/html', e.currentTarget.outerHTML);
+ }, [draggedItem]);
+
+ const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ setDragOverIndex(index);
+ }, []);
+
+ const handleDragLeave = useCallback(() => {
+ setDragOverIndex(null);
+ }, []);
+
+ const handleDrop = useCallback((e: React.DragEvent, dropIndex: number) => {
+ e.preventDefault();
+ setDragOverIndex(null);
+
+ if (!draggedItem) return;
+
+ const draggedIndex = properties.findIndex(p => p.id === draggedItem.id);
+ if (draggedIndex === dropIndex) return;
+
+ const newProperties = [...properties];
+ newProperties.splice(draggedIndex, 1);
+ newProperties.splice(dropIndex, 0, draggedItem);
+
+ setProperties(newProperties);
+ setDraggedItem(null);
+ }, [properties, draggedItem]);
+
+ const handleDragEnd = useCallback(() => {
+ setDraggedItem(null);
+ setDragOverIndex(null);
+ }, []);
+
+ const resetToDefault = () => {
+ setProperties(defaultProperties);
+ localStorage.removeItem('portfolioOrder');
+ };
+
+ // Keyboard accessibility
+ const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
+ const newProperties = [...properties];
+
+ switch (e.key) {
+ case 'ArrowUp':
+ e.preventDefault();
+ if (index > 0) {
+ [newProperties[index], newProperties[index - 1]] =
+ [newProperties[index - 1], newProperties[index]];
+ setProperties(newProperties);
+ }
+ break;
+ case 'ArrowDown':
+ e.preventDefault();
+ if (index < properties.length - 1) {
+ [newProperties[index], newProperties[index + 1]] =
+ [newProperties[index + 1], newProperties[index]];
+ setProperties(newProperties);
+ }
+ break;
+ }
+ };
+
+ return (
+
+
+
+
Your Properties
+
+ Drag and drop to reorder your portfolio
+
+
+
+
+
+ Reset Order
+
+
+ View All โ
+
+
+
+
+
+ {properties.map((property, index) => (
+
+ {/* Drag Handle */}
+ handleDragStart(e, property)}
+ onDragEnd={handleDragEnd}
+ >
+
+
+
+ {/* Draggable Property Card */}
+ handleDragStart(e, property)}
+ onDragOver={(e) => handleDragOver(e, index)}
+ onDragLeave={handleDragLeave}
+ onDrop={(e) => handleDrop(e, index)}
+ onDragEnd={handleDragEnd}
+ onKeyDown={(e) => handleKeyDown(e, index)}
+ tabIndex={0}
+ role="button"
+ aria-label={`Property ${property.name}, press arrow keys to reorder`}
+ className="cursor-move focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-xl"
+ >
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/dashboard/PropertiesList.tsx b/src/components/dashboard/PropertiesList.tsx
index b7970a45..d82c3aed 100644
--- a/src/components/dashboard/PropertiesList.tsx
+++ b/src/components/dashboard/PropertiesList.tsx
@@ -1,99 +1,7 @@
'use client';
-import { motion } from "framer-motion";
-import { PropertyCard } from "./PropertyCard";
-
-const properties = [
- {
- id: "1",
- name: "Manhattan Tower Suite",
- location: "New York, NY",
- type: "Commercial",
- value: 524000,
- tokens: 1048,
- roi: 14.2,
- monthlyIncome: 3280,
- image: "https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&auto=format&fit=crop&q=80",
- },
- {
- id: "2",
- name: "Sunset Beach Villa",
- location: "Miami, FL",
- type: "Residential",
- value: 389000,
- tokens: 778,
- roi: 11.8,
- monthlyIncome: 2450,
- image: "https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800&auto=format&fit=crop&q=80",
- },
- {
- id: "3",
- name: "Tech Hub Office Complex",
- location: "San Francisco, CA",
- type: "Commercial",
- value: 892000,
- tokens: 1784,
- roi: 9.5,
- monthlyIncome: 5620,
- image: "https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&auto=format&fit=crop&q=80",
- },
- {
- id: "4",
- name: "Industrial Logistics Park",
- location: "Dallas, TX",
- type: "Industrial",
- value: 456000,
- tokens: 912,
- roi: 8.3,
- monthlyIncome: 2890,
- image: "https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&auto=format&fit=crop&q=80",
- },
- {
- id: "5",
- name: "Downtown Luxury Lofts",
- location: "Chicago, IL",
- type: "Residential",
- value: 312000,
- tokens: 624,
- roi: -2.1,
- monthlyIncome: 1980,
- image: "https://images.unsplash.com/photo-1545324418-cc1a3fa10c00?w=800&auto=format&fit=crop&q=80",
- },
- {
- id: "6",
- name: "Mixed-Use Development",
- location: "Austin, TX",
- type: "Mixed-Use",
- value: 274520,
- tokens: 549,
- roi: 16.7,
- monthlyIncome: 2020,
- image: "https://images.unsplash.com/photo-1512917774080-9991f1c4c750?w=800&auto=format&fit=crop&q=80",
- },
-];
+import { DraggablePropertiesList } from "./DraggablePropertiesList";
export const PropertiesList = () => {
- return (
-
-
-
-
Your Properties
-
Tokenized real estate holdings
-
-
- View All โ
-
-
-
-
- {properties.map((property, index) => (
-
- ))}
-
-
- );
+ return ;
};
diff --git a/src/components/dashboard/PropertyCard.tsx b/src/components/dashboard/PropertyCard.tsx
index 787df042..3f6e04aa 100644
--- a/src/components/dashboard/PropertyCard.tsx
+++ b/src/components/dashboard/PropertyCard.tsx
@@ -87,13 +87,38 @@ export const PropertyCard = ({ property, index }: PropertyCardProps) => {
-
Tokens Held
-
- {property.tokens.toLocaleString()}
+
+ Tokens Held
+
+
+
+
+
+
+
+ {property.tokens.toLocaleString()}
+
+
navigator.clipboard.writeText(property.tokens.toString())}
+ className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
+ title="Copy token amount"
+ >
+
+
+
+
+
-
ROI
+
+ ROI
+
+
+
+
+
+
@@ -131,10 +156,34 @@ export const PropertyCard = ({ property, index }: PropertyCardProps) => {
/>
)}
-
-
- Purchase Tokens
-
+
+
{
+ const shareUrl = `${window.location.origin}/properties/${property.id}`;
+ if (navigator.share) {
+ navigator.share({
+ title: property.name,
+ text: `Check out this property: ${property.name} in ${property.location}`,
+ url: shareUrl
+ });
+ } else {
+ navigator.clipboard.writeText(shareUrl);
+ }
+ }}
+ variant="outline"
+ size="sm"
+ className="flex-1"
+ >
+
+
+
+ Share
+
+
+
+ Purchase
+
+
diff --git a/src/components/dashboard/__tests__/DraggablePropertiesList.test.tsx b/src/components/dashboard/__tests__/DraggablePropertiesList.test.tsx
new file mode 100644
index 00000000..949ff0cc
--- /dev/null
+++ b/src/components/dashboard/__tests__/DraggablePropertiesList.test.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { DraggablePropertiesList } from '../DraggablePropertiesList';
+
+// Mock localStorage
+const localStorageMock = {
+ getItem: jest.fn(),
+ setItem: jest.fn(),
+ removeItem: jest.fn(),
+ clear: jest.fn(),
+};
+Object.defineProperty(window, 'localStorage', {
+ value: localStorageMock,
+});
+
+describe('DraggablePropertiesList', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ localStorageMock.getItem.mockReturnValue(null);
+ });
+
+ it('should render property cards', () => {
+ render( );
+
+ expect(screen.getByText('Your Properties')).toBeInTheDocument();
+ expect(screen.getByText('Drag and drop to reorder your portfolio')).toBeInTheDocument();
+ });
+
+ it('should render reset order button', () => {
+ render( );
+
+ expect(screen.getByText('Reset Order')).toBeInTheDocument();
+ });
+
+ it('should save order to localStorage when reordered', async () => {
+ render( );
+
+ const firstCard = screen.getAllByTestId('property-card')[0];
+ const secondCard = screen.getAllByTestId('property-card')[1];
+
+ // Simulate drag and drop
+ fireEvent.dragStart(firstCard);
+ fireEvent.dragOver(secondCard);
+ fireEvent.drop(secondCard);
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'portfolioOrder',
+ expect.any(String)
+ );
+ });
+
+ it('should load order from localStorage on mount', () => {
+ const savedOrder = JSON.stringify(['2', '1', '3', '4', '5', '6']);
+ localStorageMock.getItem.mockReturnValue(savedOrder);
+
+ render( );
+
+ expect(localStorageMock.getItem).toHaveBeenCalledWith('portfolioOrder');
+ });
+
+ it('should reset to default order when reset button clicked', async () => {
+ render( );
+
+ const resetButton = screen.getByText('Reset Order');
+ await userEvent.click(resetButton);
+
+ expect(localStorageMock.removeItem).toHaveBeenCalledWith('portfolioOrder');
+ });
+});
diff --git a/src/components/ui/CopyButton.tsx b/src/components/ui/CopyButton.tsx
new file mode 100644
index 00000000..98a25473
--- /dev/null
+++ b/src/components/ui/CopyButton.tsx
@@ -0,0 +1,237 @@
+'use client';
+
+import React, { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Copy, Check, Share2, ExternalLink } from 'lucide-react';
+import { toast } from 'sonner';
+
+interface CopyButtonProps {
+ text: string;
+ label?: string;
+ variant?: 'default' | 'icon' | 'text';
+ size?: 'sm' | 'default' | 'lg';
+ className?: string;
+ showConfirmation?: boolean;
+ onCopy?: (copiedText: string) => void;
+}
+
+export const CopyButton: React.FC = ({
+ text,
+ label = 'Copy',
+ variant = 'default',
+ size = 'sm',
+ className = '',
+ showConfirmation = true,
+ onCopy,
+}) => {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+
+ if (showConfirmation) {
+ toast.success('Copied to clipboard!', {
+ duration: 2000,
+ position: 'top-right',
+ });
+ }
+
+ if (onCopy) {
+ onCopy(text);
+ }
+
+ // Reset copied state after 2 seconds
+ setTimeout(() => setCopied(false), 2000);
+ } catch (error) {
+ console.error('Failed to copy:', error);
+ toast.error('Failed to copy to clipboard', {
+ duration: 3000,
+ position: 'top-right',
+ });
+ }
+ };
+
+ const renderIcon = () => {
+ if (copied) {
+ return ;
+ }
+ return ;
+ };
+
+ if (variant === 'icon') {
+ return (
+
+ {renderIcon()}
+
+ );
+ }
+
+ if (variant === 'text') {
+ return (
+
+ {copied ? : }
+ {copied ? 'Copied!' : label}
+
+ );
+ }
+
+ return (
+
+ {renderIcon()}
+ {copied ? 'Copied!' : label}
+
+ );
+};
+
+// Specialized copy buttons for different use cases
+
+interface CopyAddressProps {
+ address: string;
+ className?: string;
+ showFull?: boolean;
+}
+
+export const CopyAddress: React.FC = ({
+ address,
+ className = '',
+ showFull = false,
+}) => {
+ const displayAddress = showFull ? address : `${address.slice(0, 6)}...${address.slice(-4)}`;
+
+ return (
+
+ {displayAddress}
+
+
+ );
+};
+
+interface CopyTransactionHashProps {
+ hash: string;
+ className?: string;
+ showExplorer?: boolean;
+ explorerUrl?: string;
+}
+
+export const CopyTransactionHash: React.FC = ({
+ hash,
+ className = '',
+ showExplorer = true,
+ explorerUrl,
+}) => {
+ const displayHash = `${hash.slice(0, 10)}...${hash.slice(-8)}`;
+
+ const handleViewOnExplorer = () => {
+ if (explorerUrl) {
+ window.open(explorerUrl, '_blank');
+ }
+ };
+
+ return (
+
+
+ {displayHash}
+
+
+ {showExplorer && explorerUrl && (
+
+
+
+ )}
+
+ );
+};
+
+interface CopyShareLinkProps {
+ url: string;
+ title?: string;
+ className?: string;
+}
+
+export const CopyShareLink: React.FC = ({
+ url,
+ title = 'Share',
+ className = '',
+}) => {
+ const handleShare = async () => {
+ if (navigator.share) {
+ try {
+ await navigator.share({
+ title: title,
+ url: url,
+ });
+ } catch (error) {
+ // Fallback to copy if share fails
+ await navigator.clipboard.writeText(url);
+ toast.success('Link copied to clipboard!');
+ }
+ } else {
+ // Fallback for browsers that don't support Web Share API
+ await navigator.clipboard.writeText(url);
+ toast.success('Link copied to clipboard!');
+ }
+ };
+
+ return (
+
+
+ {title}
+
+ );
+};
+
+// Hook for copy functionality
+export const useCopyToClipboard = () => {
+ const copy = async (text: string, successMessage?: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ if (successMessage) {
+ toast.success(successMessage);
+ }
+ return true;
+ } catch (error) {
+ console.error('Failed to copy:', error);
+ toast.error('Failed to copy to clipboard');
+ return false;
+ }
+ };
+
+ return { copy };
+};
diff --git a/src/components/ui/Web3Tooltip.tsx b/src/components/ui/Web3Tooltip.tsx
new file mode 100644
index 00000000..7d556cab
--- /dev/null
+++ b/src/components/ui/Web3Tooltip.tsx
@@ -0,0 +1,177 @@
+'use client';
+
+import React from 'react';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { HelpCircle } from 'lucide-react';
+
+interface Web3Term {
+ term: string;
+ definition: string;
+ example?: string;
+}
+
+const web3Terms: Record = {
+ 'gas fee': {
+ term: 'Gas Fee',
+ definition: 'A fee paid to blockchain validators for processing transactions on the network.',
+ example: 'Typical gas fees range from $5-50 depending on network congestion.'
+ },
+ 'gas': {
+ term: 'Gas Fee',
+ definition: 'A fee paid to blockchain validators for processing transactions on the network.',
+ example: 'Typical gas fees range from $5-50 depending on network congestion.'
+ },
+ 'token': {
+ term: 'Token',
+ definition: 'A digital asset built on blockchain technology that represents ownership or utility.',
+ example: 'Property tokens represent fractional ownership of real estate assets.'
+ },
+ 'tokenization': {
+ term: 'Tokenization',
+ definition: 'The process of converting rights to an asset into a digital token on a blockchain.',
+ example: 'A $1M property can be tokenized into 1,000 tokens worth $1,000 each.'
+ },
+ 'smart contract': {
+ term: 'Smart Contract',
+ definition: 'Self-executing contracts with terms directly written into code that automatically execute when conditions are met.',
+ example: 'Property transfers automatically execute when payment is confirmed.'
+ },
+ 'yield': {
+ term: 'Yield',
+ definition: 'The earnings generated from an investment over a period of time, expressed as a percentage.',
+ example: 'An 8% annual yield means $8,000 profit on a $100,000 investment.'
+ },
+ 'apy': {
+ term: 'APY (Annual Percentage Yield)',
+ definition: 'The real rate of return earned on an investment, accounting for compound interest.',
+ example: '8% APY with monthly compounding yields more than 8% simple interest.'
+ },
+ 'liquidity': {
+ term: 'Liquidity',
+ definition: 'How easily an asset can be bought or sold without affecting its market price.',
+ example: 'High liquidity means you can quickly sell your tokens at market price.'
+ },
+ 'slippage': {
+ term: 'Slippage',
+ definition: 'The difference between expected price of a trade and the price at which the trade executes.',
+ example: '2% slippage on a $10,000 trade means you receive $9,800 instead of $10,000.'
+ },
+ 'block confirmation': {
+ term: 'Block Confirmation',
+ definition: 'The process of a transaction being included in a blockchain block, making it irreversible.',
+ example: '12 confirmations typically mean a transaction is final and cannot be reversed.'
+ },
+ 'block': {
+ term: 'Block',
+ definition: 'A batch of transactions recorded together on the blockchain.',
+ example: 'Each block contains multiple transactions and is linked to the previous block.'
+ },
+ 'blockchain': {
+ term: 'Blockchain',
+ definition: 'A distributed ledger technology that records transactions across multiple computers.',
+ example: 'Ethereum blockchain enables secure, transparent property transactions.'
+ },
+ 'wallet': {
+ term: 'Wallet',
+ definition: 'A digital wallet that stores private keys and allows interaction with blockchain networks.',
+ example: 'MetaMask is a popular wallet for storing crypto and interacting with dApps.'
+ },
+ 'dapp': {
+ term: 'dApp (Decentralized Application)',
+ definition: 'An application built on blockchain technology that operates without central control.',
+ example: 'PropChain is a dApp for tokenized real estate investment.'
+ },
+ 'defi': {
+ term: 'DeFi (Decentralized Finance)',
+ definition: 'Financial services built on blockchain technology that operate without traditional intermediaries.',
+ example: 'Lending, borrowing, and trading without banks using smart contracts.'
+ }
+};
+
+interface Web3TooltipProps {
+ term: string;
+ children?: React.ReactNode;
+ className?: string;
+ showIcon?: boolean;
+}
+
+export const Web3Tooltip: React.FC = ({
+ term,
+ children,
+ className = '',
+ showIcon = true,
+}) => {
+ const normalizedTerm = term.toLowerCase();
+ const web3Info = web3Terms[normalizedTerm];
+
+ if (!web3Info) {
+ return <>{children || term}>;
+ }
+
+ return (
+
+
+
+
+ {children || term}
+ {showIcon && (
+
+ )}
+
+
+
+
+
+ {web3Info.term}
+
+
+ {web3Info.definition}
+
+ {web3Info.example && (
+
+
+ Example: {web3Info.example}
+
+
+ )}
+
+
+
+
+ );
+};
+
+// Hook to automatically wrap text with Web3 tooltips
+export const useWeb3Tooltips = () => {
+ const wrapWithTooltip = (text: string, className?: string) => {
+ const words = text.split(' ');
+ return words.map((word, index) => {
+ const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '');
+ const punctuation = word.match(/[.,!?;:]$/)?.[0] || '';
+
+ if (web3Terms[cleanWord]) {
+ return (
+
+
+ {word.replace(/[.,!?;:]/g, '')}
+
+ {punctuation}{' '}
+
+ );
+ }
+
+ return `${word} `;
+ });
+ };
+
+ return { wrapWithTooltip };
+};
diff --git a/src/components/ui/__tests__/CopyButton.test.tsx b/src/components/ui/__tests__/CopyButton.test.tsx
new file mode 100644
index 00000000..c7ca9e7c
--- /dev/null
+++ b/src/components/ui/__tests__/CopyButton.test.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { CopyButton } from '../CopyButton';
+
+// Mock clipboard API
+Object.assign(navigator, {
+ clipboard: {
+ writeText: jest.fn().mockResolvedValue(undefined),
+ },
+});
+
+describe('CopyButton', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render copy button with default variant', () => {
+ render( );
+
+ expect(screen.getByText('Copy')).toBeInTheDocument();
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('should render copy button with icon variant', () => {
+ render( );
+
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ expect(screen.queryByText('Copy')).not.toBeInTheDocument();
+ });
+
+ it('should render copy button with text variant', () => {
+ render( );
+
+ expect(screen.getByText('Copy Text')).toBeInTheDocument();
+ });
+
+ it('should copy text to clipboard when clicked', async () => {
+ render( );
+
+ const button = screen.getByRole('button');
+ await userEvent.click(button);
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test text');
+ });
+
+ it('should show copied state after click', async () => {
+ render( );
+
+ const button = screen.getByRole('button');
+ await userEvent.click(button);
+
+ expect(screen.getByText('Copied!')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/ui/__tests__/Web3Tooltip.test.tsx b/src/components/ui/__tests__/Web3Tooltip.test.tsx
new file mode 100644
index 00000000..83faa8fe
--- /dev/null
+++ b/src/components/ui/__tests__/Web3Tooltip.test.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Web3Tooltip } from '../Web3Tooltip';
+
+describe('Web3Tooltip', () => {
+ it('should render children with tooltip icon', () => {
+ render(Gas Fee );
+
+ expect(screen.getByText('Gas Fee')).toBeInTheDocument();
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('should not render tooltip for unknown terms', () => {
+ render(Unknown );
+
+ expect(screen.getByText('Unknown')).toBeInTheDocument();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('should render without icon when showIcon is false', () => {
+ render(Gas Fee );
+
+ expect(screen.getByText('Gas Fee')).toBeInTheDocument();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('should apply custom className', () => {
+ render(Gas Fee );
+
+ const element = screen.getByText('Gas Fee');
+ expect(element).toHaveClass('custom-class');
+ });
+
+ it('should show tooltip on hover', async () => {
+ render(Gas Fee );
+
+ const trigger = screen.getByRole('button');
+ await userEvent.hover(trigger);
+
+ // Tooltip content should appear
+ expect(screen.getByText(/fee paid to blockchain validators/)).toBeInTheDocument();
+ });
+});