Add 10 major features to complete the desktop wallet: - Staking: Stake SYN tokens for rewards with pool management - DEX/Swap: Built-in token swap interface with liquidity pools - Address Book: Save and manage frequently used addresses - DApp Browser: Interact with decentralized applications - Hardware Wallet: Ledger/Trezor support for secure signing - Multi-sig Wallets: Require multiple signatures for transactions - Price Charts: Market data and real-time price tracking - Notifications: Push notifications for transactions and alerts - QR Scanner: Generate and parse payment QR codes - Backup/Export: Encrypted wallet backup and recovery Includes Tauri backend commands for all features, Zustand stores for state management, and complete UI pages with navigation.
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
import { useState } from 'react';
|
||
import {
|
||
QrCode,
|
||
Camera,
|
||
Copy,
|
||
Check,
|
||
AlertCircle,
|
||
Download,
|
||
Upload,
|
||
RefreshCw,
|
||
} from 'lucide-react';
|
||
import { invoke } from '@tauri-apps/api/core';
|
||
|
||
interface PaymentRequest {
|
||
address: string;
|
||
amount?: number;
|
||
label?: string;
|
||
message?: string;
|
||
}
|
||
|
||
export default function QRScannerPage() {
|
||
const [activeTab, setActiveTab] = useState<'generate' | 'scan'>('generate');
|
||
const [address, setAddress] = useState('');
|
||
const [amount, setAmount] = useState('');
|
||
const [label, setLabel] = useState('');
|
||
const [message, setMessage] = useState('');
|
||
const [qrCodeData, setQrCodeData] = useState<string | null>(null);
|
||
const [isGenerating, setIsGenerating] = useState(false);
|
||
const [copiedUri, setCopiedUri] = useState(false);
|
||
const [scannedData, setScannedData] = useState<PaymentRequest | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const generatePaymentUri = () => {
|
||
let uri = `synor:${address}`;
|
||
const params: string[] = [];
|
||
|
||
if (amount) params.push(`amount=${amount}`);
|
||
if (label) params.push(`label=${encodeURIComponent(label)}`);
|
||
if (message) params.push(`message=${encodeURIComponent(message)}`);
|
||
|
||
if (params.length > 0) {
|
||
uri += '?' + params.join('&');
|
||
}
|
||
|
||
return uri;
|
||
};
|
||
|
||
const handleGenerateQR = async () => {
|
||
if (!address) return;
|
||
|
||
setIsGenerating(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const uri = generatePaymentUri();
|
||
const qrData = await invoke<string>('qr_generate', {
|
||
data: uri,
|
||
size: 256,
|
||
});
|
||
setQrCodeData(qrData);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to generate QR code');
|
||
} finally {
|
||
setIsGenerating(false);
|
||
}
|
||
};
|
||
|
||
const handlePasteUri = async () => {
|
||
try {
|
||
const text = await navigator.clipboard.readText();
|
||
parsePaymentUri(text);
|
||
} catch {
|
||
setError('Failed to read clipboard');
|
||
}
|
||
};
|
||
|
||
const parsePaymentUri = async (uri: string) => {
|
||
setError(null);
|
||
|
||
try {
|
||
const parsed = await invoke<PaymentRequest>('qr_parse_payment', { uri });
|
||
setScannedData(parsed);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Invalid payment URI');
|
||
}
|
||
};
|
||
|
||
const copyUri = () => {
|
||
const uri = generatePaymentUri();
|
||
navigator.clipboard.writeText(uri);
|
||
setCopiedUri(true);
|
||
setTimeout(() => setCopiedUri(false), 2000);
|
||
};
|
||
|
||
const downloadQR = () => {
|
||
if (!qrCodeData) return;
|
||
|
||
const link = document.createElement('a');
|
||
link.download = `synor-payment-${Date.now()}.png`;
|
||
link.href = qrCodeData;
|
||
link.click();
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-white">QR Code</h1>
|
||
<p className="text-gray-400 mt-1">Generate and scan payment QR codes</p>
|
||
</div>
|
||
|
||
{/* Error Alert */}
|
||
{error && (
|
||
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||
<AlertCircle className="text-red-400" size={20} />
|
||
<p className="text-red-400 flex-1">{error}</p>
|
||
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tabs */}
|
||
<div className="flex gap-2 border-b border-gray-800">
|
||
<button
|
||
onClick={() => setActiveTab('generate')}
|
||
className={`px-4 py-2 font-medium transition-colors ${
|
||
activeTab === 'generate'
|
||
? 'text-synor-400 border-b-2 border-synor-400'
|
||
: 'text-gray-400 hover:text-white'
|
||
}`}
|
||
>
|
||
Generate QR
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('scan')}
|
||
className={`px-4 py-2 font-medium transition-colors ${
|
||
activeTab === 'scan'
|
||
? 'text-synor-400 border-b-2 border-synor-400'
|
||
: 'text-gray-400 hover:text-white'
|
||
}`}
|
||
>
|
||
Parse Payment
|
||
</button>
|
||
</div>
|
||
|
||
{/* Generate Tab */}
|
||
{activeTab === 'generate' && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
{/* Form */}
|
||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||
<h2 className="text-lg font-semibold text-white mb-4">Payment Details</h2>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">
|
||
Receiving Address *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={address}
|
||
onChange={(e) => setAddress(e.target.value)}
|
||
placeholder="synor1..."
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">
|
||
Amount (SYN) - Optional
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={amount}
|
||
onChange={(e) => setAmount(e.target.value)}
|
||
placeholder="0.0"
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">
|
||
Label - Optional
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={label}
|
||
onChange={(e) => setLabel(e.target.value)}
|
||
placeholder="e.g., Coffee Shop"
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">
|
||
Message - Optional
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={message}
|
||
onChange={(e) => setMessage(e.target.value)}
|
||
placeholder="e.g., Payment for order #123"
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleGenerateQR}
|
||
disabled={!address || isGenerating}
|
||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||
>
|
||
{isGenerating ? (
|
||
<>
|
||
<RefreshCw size={18} className="animate-spin" />
|
||
Generating...
|
||
</>
|
||
) : (
|
||
<>
|
||
<QrCode size={18} />
|
||
Generate QR Code
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* QR Display */}
|
||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||
<h2 className="text-lg font-semibold text-white mb-4">QR Code</h2>
|
||
|
||
<div className="flex flex-col items-center">
|
||
{qrCodeData ? (
|
||
<>
|
||
<div className="p-4 bg-white rounded-xl">
|
||
<img
|
||
src={qrCodeData}
|
||
alt="Payment QR Code"
|
||
className="w-64 h-64"
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-4 w-full">
|
||
<label className="block text-sm text-gray-400 mb-1">
|
||
Payment URI
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={generatePaymentUri()}
|
||
readOnly
|
||
className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm font-mono"
|
||
/>
|
||
<button
|
||
onClick={copyUri}
|
||
className="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||
>
|
||
{copiedUri ? (
|
||
<Check size={18} className="text-green-400" />
|
||
) : (
|
||
<Copy size={18} className="text-gray-400" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={downloadQR}
|
||
className="mt-4 flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||
>
|
||
<Download size={18} />
|
||
Download QR Code
|
||
</button>
|
||
</>
|
||
) : (
|
||
<div className="w-64 h-64 bg-gray-800 rounded-xl flex items-center justify-center">
|
||
<div className="text-center text-gray-500">
|
||
<QrCode size={48} className="mx-auto mb-2 opacity-50" />
|
||
<p>Enter details to generate</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Scan Tab */}
|
||
{activeTab === 'scan' && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
{/* Input */}
|
||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||
<h2 className="text-lg font-semibold text-white mb-4">Parse Payment URI</h2>
|
||
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-gray-400">
|
||
Paste a Synor payment URI to decode its contents. The URI format is:
|
||
</p>
|
||
<code className="block p-3 bg-gray-800 rounded-lg text-sm text-gray-300 break-all">
|
||
synor:{'<address>'}?amount={'<amount>'}&label={'<label>'}
|
||
</code>
|
||
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">
|
||
Payment URI
|
||
</label>
|
||
<textarea
|
||
placeholder="Paste synor:... URI here"
|
||
rows={3}
|
||
onChange={(e) => {
|
||
if (e.target.value) {
|
||
parsePaymentUri(e.target.value);
|
||
} else {
|
||
setScannedData(null);
|
||
}
|
||
}}
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500 resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handlePasteUri}
|
||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
|
||
>
|
||
<Upload size={18} />
|
||
Paste from Clipboard
|
||
</button>
|
||
|
||
<div className="pt-4 border-t border-gray-800">
|
||
<p className="text-sm text-gray-500">
|
||
Note: Camera scanning is not available in desktop apps. You can use
|
||
your phone's camera to scan QR codes and copy the resulting URI here.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Parsed Result */}
|
||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||
<h2 className="text-lg font-semibold text-white mb-4">Payment Request</h2>
|
||
|
||
{scannedData ? (
|
||
<div className="space-y-4">
|
||
<div className="p-4 bg-green-900/20 border border-green-800 rounded-lg">
|
||
<div className="flex items-center gap-2 text-green-400 mb-2">
|
||
<Check size={18} />
|
||
<span className="font-medium">Valid Payment Request</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-gray-500 mb-1">Address</label>
|
||
<code className="block p-3 bg-gray-800 rounded-lg text-sm text-white break-all">
|
||
{scannedData.address}
|
||
</code>
|
||
</div>
|
||
|
||
{scannedData.amount !== undefined && (
|
||
<div>
|
||
<label className="block text-sm text-gray-500 mb-1">Amount</label>
|
||
<p className="text-2xl font-bold text-white">
|
||
{scannedData.amount} SYN
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{scannedData.label && (
|
||
<div>
|
||
<label className="block text-sm text-gray-500 mb-1">Label</label>
|
||
<p className="text-white">{scannedData.label}</p>
|
||
</div>
|
||
)}
|
||
|
||
{scannedData.message && (
|
||
<div>
|
||
<label className="block text-sm text-gray-500 mb-1">Message</label>
|
||
<p className="text-white">{scannedData.message}</p>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
onClick={() => {
|
||
// Navigate to send page with pre-filled data
|
||
window.location.href = `/send?to=${scannedData.address}${
|
||
scannedData.amount ? `&amount=${scannedData.amount}` : ''
|
||
}`;
|
||
}}
|
||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||
>
|
||
Send Payment
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="h-64 flex items-center justify-center text-gray-500">
|
||
<div className="text-center">
|
||
<Camera size={48} className="mx-auto mb-2 opacity-50" />
|
||
<p>Paste a payment URI to decode</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|