feat(web-wallet): add QR code generation and scanning support
- Add QRCode component using qrcode.react (SVG-based, H error correction) - Add QRScanner component using html5-qrcode for camera-based scanning - Update Receive page with real QR code and payment request amount - Update Send page with QR scan button to auto-fill recipient address - Support Synor payment URI format: synor:address?amount=X - Add Dockerfile for web wallet with nginx serving - Add web-wallet service to docker-compose.testnet.yml (port 17300) - Fix TypeScript WebCrypto ArrayBuffer type issues QR features: - SVG rendering for crisp display at any size - Error correction level H (30% recovery) for printed codes - Camera permission handling with user-friendly error states - Auto-fill amount when scanning payment requests
This commit is contained in:
parent
e0475176c5
commit
a439389943
11 changed files with 430 additions and 32 deletions
39
apps/web/Dockerfile
Normal file
39
apps/web/Dockerfile
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Dockerfile for Synor Web Wallet
|
||||||
|
# Multi-stage build for optimized production image
|
||||||
|
|
||||||
|
# Stage 1: Build
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Serve with nginx
|
||||||
|
FROM nginx:alpine AS production
|
||||||
|
|
||||||
|
# Copy build output
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy custom nginx config for SPA routing
|
||||||
|
RUN echo 'server { \
|
||||||
|
listen 80; \
|
||||||
|
root /usr/share/nginx/html; \
|
||||||
|
index index.html; \
|
||||||
|
location / { \
|
||||||
|
try_files $uri $uri/ /index.html; \
|
||||||
|
} \
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { \
|
||||||
|
expires 1y; \
|
||||||
|
add_header Cache-Control "public, immutable"; \
|
||||||
|
} \
|
||||||
|
}' > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
@ -17,7 +17,9 @@
|
||||||
"@noble/hashes": "^1.3.3",
|
"@noble/hashes": "^1.3.3",
|
||||||
"@noble/ed25519": "^2.1.0",
|
"@noble/ed25519": "^2.1.0",
|
||||||
"bip39": "^3.1.0",
|
"bip39": "^3.1.0",
|
||||||
"zustand": "^4.5.0"
|
"zustand": "^4.5.0",
|
||||||
|
"qrcode.react": "^3.1.0",
|
||||||
|
"html5-qrcode": "^2.3.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
|
|
|
||||||
100
apps/web/src/components/QRCode.tsx
Normal file
100
apps/web/src/components/QRCode.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
|
||||||
|
interface QRCodeProps {
|
||||||
|
value: string;
|
||||||
|
size?: number;
|
||||||
|
includeMargin?: boolean;
|
||||||
|
level?: 'L' | 'M' | 'Q' | 'H';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR Code display component using SVG rendering.
|
||||||
|
*
|
||||||
|
* For Synor wallet addresses, the QR code encodes the full address
|
||||||
|
* in the format: synor:<bech32-address>
|
||||||
|
*
|
||||||
|
* Error correction levels:
|
||||||
|
* - L: ~7% recovery
|
||||||
|
* - M: ~15% recovery (default)
|
||||||
|
* - Q: ~25% recovery
|
||||||
|
* - H: ~30% recovery (recommended for addresses)
|
||||||
|
*/
|
||||||
|
export function QRCode({
|
||||||
|
value,
|
||||||
|
size = 200,
|
||||||
|
includeMargin = true,
|
||||||
|
level = 'H',
|
||||||
|
className = '',
|
||||||
|
}: QRCodeProps) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-white p-4 rounded-lg inline-block ${className}`}>
|
||||||
|
<QRCodeSVG
|
||||||
|
value={value}
|
||||||
|
size={size}
|
||||||
|
level={level}
|
||||||
|
includeMargin={includeMargin}
|
||||||
|
bgColor="#ffffff"
|
||||||
|
fgColor="#000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a Synor payment URI for QR codes.
|
||||||
|
* Format: synor:<address>?amount=<amount>&label=<label>&message=<message>
|
||||||
|
*/
|
||||||
|
export function createPaymentUri(
|
||||||
|
address: string,
|
||||||
|
options?: {
|
||||||
|
amount?: string;
|
||||||
|
label?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (options?.amount) {
|
||||||
|
params.set('amount', options.amount);
|
||||||
|
}
|
||||||
|
if (options?.label) {
|
||||||
|
params.set('label', options.label);
|
||||||
|
}
|
||||||
|
if (options?.message) {
|
||||||
|
params.set('message', options.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
return queryString ? `${address}?${queryString}` : address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Synor payment URI from a QR code scan.
|
||||||
|
*/
|
||||||
|
export function parsePaymentUri(uri: string): {
|
||||||
|
address: string;
|
||||||
|
amount?: string;
|
||||||
|
label?: string;
|
||||||
|
message?: string;
|
||||||
|
} | null {
|
||||||
|
// Handle both plain addresses and URIs with parameters
|
||||||
|
if (!uri.startsWith('synor:')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [addressPart, queryPart] = uri.split('?');
|
||||||
|
const address = addressPart;
|
||||||
|
|
||||||
|
if (!queryPart) {
|
||||||
|
return { address };
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(queryPart);
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
amount: params.get('amount') || undefined,
|
||||||
|
label: params.get('label') || undefined,
|
||||||
|
message: params.get('message') || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
164
apps/web/src/components/QRScanner.tsx
Normal file
164
apps/web/src/components/QRScanner.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Html5Qrcode } from 'html5-qrcode';
|
||||||
|
|
||||||
|
interface QRScannerProps {
|
||||||
|
onScan: (result: string) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR Scanner component using device camera.
|
||||||
|
*
|
||||||
|
* Supports both rear and front cameras. On desktop, will use webcam.
|
||||||
|
* On mobile, defaults to rear camera for easier scanning.
|
||||||
|
*/
|
||||||
|
export function QRScanner({ onScan, onError, onClose }: QRScannerProps) {
|
||||||
|
const scannerRef = useRef<Html5Qrcode | null>(null);
|
||||||
|
const [isStarting, setIsStarting] = useState(true);
|
||||||
|
const [permissionDenied, setPermissionDenied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scannerId = 'qr-reader';
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const startScanner = async () => {
|
||||||
|
try {
|
||||||
|
const scanner = new Html5Qrcode(scannerId);
|
||||||
|
scannerRef.current = scanner;
|
||||||
|
|
||||||
|
await scanner.start(
|
||||||
|
{ facingMode: 'environment' },
|
||||||
|
{
|
||||||
|
fps: 10,
|
||||||
|
qrbox: { width: 250, height: 250 },
|
||||||
|
aspectRatio: 1,
|
||||||
|
},
|
||||||
|
(decodedText) => {
|
||||||
|
// Successfully scanned
|
||||||
|
scanner.stop().catch(() => {});
|
||||||
|
onScan(decodedText);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Scan error (ignore - just means no QR found in frame)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setIsStarting(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (mounted) {
|
||||||
|
setIsStarting(false);
|
||||||
|
if (err instanceof Error && err.message.includes('Permission')) {
|
||||||
|
setPermissionDenied(true);
|
||||||
|
}
|
||||||
|
onError?.(err instanceof Error ? err.message : 'Camera error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
startScanner();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
if (scannerRef.current) {
|
||||||
|
scannerRef.current.stop().catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [onScan, onError]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/90 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-black/50">
|
||||||
|
<h2 className="text-lg font-semibold">Scan QR Code</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-slate-800 rounded-lg transition-colors"
|
||||||
|
aria-label="Close scanner"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scanner area */}
|
||||||
|
<div className="flex-1 flex items-center justify-center p-4">
|
||||||
|
<div className="relative w-full max-w-sm">
|
||||||
|
{isStarting && !permissionDenied && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-slate-900 rounded-lg">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin w-8 h-8 border-2 border-synor-400 border-t-transparent rounded-full mx-auto mb-2" />
|
||||||
|
<p className="text-slate-400">Starting camera...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{permissionDenied && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-slate-900 rounded-lg p-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-900/50 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-red-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Camera Access Denied</h3>
|
||||||
|
<p className="text-slate-400 text-sm mb-4">
|
||||||
|
Please allow camera access in your browser settings to scan QR codes.
|
||||||
|
</p>
|
||||||
|
<button onClick={onClose} className="btn btn-secondary">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="qr-reader"
|
||||||
|
className="w-full aspect-square rounded-lg overflow-hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scan guide overlay */}
|
||||||
|
{!isStarting && !permissionDenied && (
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
<div className="absolute inset-0 border-2 border-synor-400/30 rounded-lg" />
|
||||||
|
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-synor-400 rounded-tl-lg" />
|
||||||
|
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-synor-400 rounded-tr-lg" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-synor-400 rounded-bl-lg" />
|
||||||
|
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-synor-400 rounded-br-lg" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="p-4 text-center bg-black/50">
|
||||||
|
<p className="text-slate-400 text-sm">
|
||||||
|
Point your camera at a Synor address QR code
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -185,9 +185,9 @@ export async function encrypt(
|
||||||
|
|
||||||
const key = await deriveEncryptionKey(password, salt);
|
const key = await deriveEncryptionKey(password, salt);
|
||||||
const ciphertext = await crypto.subtle.encrypt(
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
{ name: 'AES-GCM', iv },
|
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
||||||
key,
|
key,
|
||||||
data
|
data.buffer as ArrayBuffer
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -208,9 +208,9 @@ export async function decrypt(
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const key = await deriveEncryptionKey(password, salt);
|
const key = await deriveEncryptionKey(password, salt);
|
||||||
const plaintext = await crypto.subtle.decrypt(
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
{ name: 'AES-GCM', iv },
|
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
||||||
key,
|
key,
|
||||||
ciphertext
|
ciphertext.buffer as ArrayBuffer
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Uint8Array(plaintext);
|
return new Uint8Array(plaintext);
|
||||||
|
|
@ -235,7 +235,7 @@ async function deriveEncryptionKey(
|
||||||
return await crypto.subtle.deriveKey(
|
return await crypto.subtle.deriveKey(
|
||||||
{
|
{
|
||||||
name: 'PBKDF2',
|
name: 'PBKDF2',
|
||||||
salt,
|
salt: salt.buffer as ArrayBuffer,
|
||||||
iterations: 100000,
|
iterations: 100000,
|
||||||
hash: 'SHA-256',
|
hash: 'SHA-256',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,8 @@ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
||||||
import {
|
import {
|
||||||
hash,
|
hash,
|
||||||
type Keypair,
|
type Keypair,
|
||||||
type HybridSignature,
|
|
||||||
createHybridSignatureLocal,
|
createHybridSignatureLocal,
|
||||||
createHybridSignatureSmart,
|
createHybridSignatureSmart,
|
||||||
serializeHybridSignature,
|
|
||||||
type SigningConfig,
|
type SigningConfig,
|
||||||
} from './crypto';
|
} from './crypto';
|
||||||
import { getClient, type Utxo } from './rpc';
|
import { getClient, type Utxo } from './rpc';
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,10 @@ export async function loadWasmCrypto(): Promise<SynorCryptoWasm> {
|
||||||
|
|
||||||
// For wasm-bindgen bundler target, the default export is the init function
|
// For wasm-bindgen bundler target, the default export is the init function
|
||||||
// Some bundlers auto-init, others require explicit init call
|
// Some bundlers auto-init, others require explicit init call
|
||||||
if (typeof wasm.default === 'function') {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await wasm.default();
|
const initFn = (wasm as any).default;
|
||||||
|
if (typeof initFn === 'function') {
|
||||||
|
await initFn();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cast to our interface type
|
// Cast to our interface type
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useWalletStore } from '../store/wallet';
|
import { useWalletStore } from '../store/wallet';
|
||||||
|
import { QRCode, createPaymentUri } from '../components/QRCode';
|
||||||
|
|
||||||
export default function ReceivePage() {
|
export default function ReceivePage() {
|
||||||
const { address } = useWalletStore();
|
const { address } = useWalletStore();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [requestAmount, setRequestAmount] = useState('');
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
if (address) {
|
if (address) {
|
||||||
|
|
@ -13,20 +15,29 @@ export default function ReceivePage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Generate payment URI with optional amount
|
||||||
|
const qrValue = useMemo(() => {
|
||||||
|
if (!address) return '';
|
||||||
|
return createPaymentUri(address, requestAmount ? { amount: requestAmount } : undefined);
|
||||||
|
}, [address, requestAmount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto">
|
<div className="max-w-md mx-auto">
|
||||||
<h1 className="text-2xl font-bold mb-6">Receive SYNOR</h1>
|
<h1 className="text-2xl font-bold mb-6">Receive SYNOR</h1>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{/* QR Code placeholder */}
|
{/* QR Code */}
|
||||||
<div className="bg-white p-6 rounded-lg mb-6 flex items-center justify-center">
|
<div className="flex items-center justify-center mb-6">
|
||||||
<div className="w-48 h-48 bg-slate-200 rounded flex items-center justify-center text-slate-500">
|
{address ? (
|
||||||
{/* In production, render actual QR code */}
|
<QRCode value={qrValue} size={200} />
|
||||||
<div className="text-center">
|
) : (
|
||||||
<div className="text-6xl mb-2">◈</div>
|
<div className="w-48 h-48 bg-slate-200 rounded flex items-center justify-center text-slate-500">
|
||||||
<div className="text-sm">QR Code</div>
|
<div className="text-center">
|
||||||
|
<div className="text-4xl mb-2">?</div>
|
||||||
|
<div className="text-sm">No wallet loaded</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address */}
|
{/* Address */}
|
||||||
|
|
@ -58,7 +69,7 @@ export default function ReceivePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Request Payment (optional feature) */}
|
{/* Request Payment */}
|
||||||
<div className="card mt-6">
|
<div className="card mt-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">Request Payment</h2>
|
<h2 className="text-lg font-semibold mb-4">Request Payment</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -69,6 +80,8 @@ export default function ReceivePage() {
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
value={requestAmount}
|
||||||
|
onChange={(e) => setRequestAmount(e.target.value)}
|
||||||
className="input pr-20"
|
className="input pr-20"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
step="0.00000001"
|
step="0.00000001"
|
||||||
|
|
@ -79,8 +92,19 @@ export default function ReceivePage() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="w-full btn btn-secondary">
|
{requestAmount && parseFloat(requestAmount) > 0 && (
|
||||||
Generate Payment Link
|
<div className="p-3 bg-synor-900/30 border border-synor-700/50 rounded-lg">
|
||||||
|
<p className="text-sm text-synor-300">
|
||||||
|
QR code updated to request {requestAmount} SYNOR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="w-full btn btn-secondary"
|
||||||
|
disabled={!address}
|
||||||
|
>
|
||||||
|
Copy Payment Link
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useWalletStore } from '../store/wallet';
|
import { useWalletStore } from '../store/wallet';
|
||||||
import { createSendTransaction, serializeTransaction, fromSomas, toSomas } from '../lib/transaction';
|
import { createSendTransaction, serializeTransaction } from '../lib/transaction';
|
||||||
import { getClient } from '../lib/rpc';
|
import { getClient } from '../lib/rpc';
|
||||||
|
import { QRScanner } from '../components/QRScanner';
|
||||||
|
import { parsePaymentUri } from '../components/QRCode';
|
||||||
|
|
||||||
export default function SendPage() {
|
export default function SendPage() {
|
||||||
const { address, keypair, balance, refreshBalance } = useWalletStore();
|
const { address, keypair, balance, refreshBalance } = useWalletStore();
|
||||||
|
|
@ -13,6 +15,23 @@ export default function SendPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState<{ txId: string } | null>(null);
|
const [success, setSuccess] = useState<{ txId: string } | null>(null);
|
||||||
|
const [showScanner, setShowScanner] = useState(false);
|
||||||
|
|
||||||
|
const handleQRScan = (scannedText: string) => {
|
||||||
|
setShowScanner(false);
|
||||||
|
const parsed = parsePaymentUri(scannedText);
|
||||||
|
if (parsed) {
|
||||||
|
setRecipient(parsed.address);
|
||||||
|
if (parsed.amount) {
|
||||||
|
setAmount(parsed.amount);
|
||||||
|
}
|
||||||
|
} else if (scannedText.startsWith('synor:')) {
|
||||||
|
// Plain address without parameters
|
||||||
|
setRecipient(scannedText);
|
||||||
|
} else {
|
||||||
|
setError('Invalid QR code. Expected a Synor address.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSend = async (e: React.FormEvent) => {
|
const handleSend = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -123,14 +142,36 @@ export default function SendPage() {
|
||||||
<label className="block text-sm text-slate-400 mb-2">
|
<label className="block text-sm text-slate-400 mb-2">
|
||||||
Recipient Address
|
Recipient Address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
type="text"
|
<input
|
||||||
value={recipient}
|
type="text"
|
||||||
onChange={(e) => setRecipient(e.target.value)}
|
value={recipient}
|
||||||
className="input font-mono text-sm"
|
onChange={(e) => setRecipient(e.target.value)}
|
||||||
placeholder="synor:qz..."
|
className="input font-mono text-sm flex-1"
|
||||||
autoFocus
|
placeholder="synor:qz..."
|
||||||
/>
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowScanner(true)}
|
||||||
|
className="btn btn-secondary px-3"
|
||||||
|
title="Scan QR Code"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -182,6 +223,17 @@ export default function SendPage() {
|
||||||
{isLoading ? 'Sending...' : 'Send Transaction'}
|
{isLoading ? 'Sending...' : 'Send Transaction'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* QR Scanner Modal */}
|
||||||
|
{showScanner && (
|
||||||
|
<QRScanner
|
||||||
|
onScan={handleQRScan}
|
||||||
|
onClose={() => setShowScanner(false)}
|
||||||
|
onError={(err) => {
|
||||||
|
console.error('QR scan error:', err);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,6 @@ export const useWalletStore = create<WalletState>()(
|
||||||
password
|
password
|
||||||
);
|
);
|
||||||
|
|
||||||
const wallet = await createWallet('', '', network);
|
|
||||||
// Re-derive keypair from decrypted seed
|
// Re-derive keypair from decrypted seed
|
||||||
const { deriveKeypair, publicKeyToAddress } = await import('../lib/crypto');
|
const { deriveKeypair, publicKeyToAddress } = await import('../lib/crypto');
|
||||||
const keypair = await deriveKeypair(seed);
|
const keypair = await deriveKeypair(seed);
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,24 @@ services:
|
||||||
seed1:
|
seed1:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Web Wallet (React + Vite)
|
||||||
|
# ==========================================================================
|
||||||
|
web-wallet:
|
||||||
|
build:
|
||||||
|
context: ./apps/web
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: synor-web-wallet
|
||||||
|
hostname: web-wallet
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "17300:80"
|
||||||
|
networks:
|
||||||
|
- synor-testnet
|
||||||
|
depends_on:
|
||||||
|
seed1:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# Testnet Faucet Service
|
# Testnet Faucet Service
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue