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 '../../lib/tauri';
|
||
|
||
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>
|
||
);
|
||
}
|