Skip to content

Commit 1b0df06

Browse files
authored
Merge pull request #228 from EzeanoroEbuka/fix-and-enhance/wallet-mismatch-and-image-compression
fix & enhance(frontend): resolve network banner render loop and imple…
2 parents 643db58 + d83c98e commit 1b0df06

10 files changed

Lines changed: 344 additions & 50 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
'use client';
2+
3+
import { useCallback, useMemo, useRef, useState } from 'react';
4+
import { Upload, Loader } from 'lucide-react';
5+
import { useImageCompressor } from '@/hooks/useImageCompressor';
6+
import { uploadService } from '@/services/uploadService';
7+
8+
export default function ImageCapture() {
9+
const fileInputRef = useRef<HTMLInputElement | null>(null);
10+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
11+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
12+
const [isUploading, setIsUploading] = useState(false);
13+
const [uploadResult, setUploadResult] = useState<string | null>(null);
14+
const { compress, isCompressing } = useImageCompressor();
15+
16+
const handleFileChange = useCallback(
17+
async (e: React.ChangeEvent<HTMLInputElement>) => {
18+
const file = e.target.files?.[0] ?? null;
19+
if (!file) return;
20+
21+
// Create preview immediately
22+
const url = URL.createObjectURL(file);
23+
setPreviewUrl(url);
24+
setSelectedFile(file);
25+
setUploadResult(null);
26+
},
27+
[]
28+
);
29+
30+
const compressedInfo = useMemo(() => {
31+
if (!selectedFile) return null;
32+
return {
33+
name: selectedFile.name,
34+
sizeKB: (selectedFile.size / 1024).toFixed(2),
35+
};
36+
}, [selectedFile]);
37+
38+
const handleUpload = useCallback(async () => {
39+
if (!selectedFile) return;
40+
setIsUploading(true);
41+
setUploadResult(null);
42+
try {
43+
// Compress to under 500KB
44+
const compressed = await compress(selectedFile, 500);
45+
46+
// Optionally show compressed size in result
47+
const compressedSizeKB = (compressed.size / 1024).toFixed(2);
48+
49+
const response = await uploadService.uploadFile(compressed, 'proof');
50+
if (response.success) {
51+
setUploadResult(
52+
`Uploaded ${response.data?.fileName ?? compressed.name} (${compressedSizeKB} KB)`
53+
);
54+
// cleanup preview
55+
if (previewUrl) {
56+
URL.revokeObjectURL(previewUrl);
57+
setPreviewUrl(null);
58+
}
59+
setSelectedFile(null);
60+
} else {
61+
setUploadResult(
62+
`Upload failed: ${response.message ?? 'unknown error'}`
63+
);
64+
}
65+
} catch (err: any) {
66+
setUploadResult(err?.message ?? 'Upload error');
67+
} finally {
68+
setIsUploading(false);
69+
}
70+
}, [selectedFile, compress, previewUrl]);
71+
72+
return (
73+
<div className="max-w-md mx-auto p-4">
74+
<h2 className="text-lg font-semibold mb-3">Capture Proof of Delivery</h2>
75+
76+
<div className="mb-3">
77+
<input
78+
ref={fileInputRef}
79+
type="file"
80+
accept="image/*"
81+
capture="environment"
82+
onChange={handleFileChange}
83+
className="block"
84+
/>
85+
</div>
86+
87+
{previewUrl && (
88+
<div className="mb-3">
89+
<img
90+
src={previewUrl}
91+
alt="preview"
92+
className="w-full rounded-md border"
93+
/>
94+
</div>
95+
)}
96+
97+
{compressedInfo && (
98+
<p className="text-sm text-gray-600 mb-2">
99+
Original: {compressedInfo.name}{compressedInfo.sizeKB} KB
100+
</p>
101+
)}
102+
103+
<div className="flex items-center gap-2">
104+
<button
105+
type="button"
106+
onClick={() => fileInputRef.current?.click()}
107+
className="inline-flex items-center gap-2 px-3 py-1.5 rounded border bg-white"
108+
>
109+
<Upload className="w-4 h-4" />
110+
Choose Photo
111+
</button>
112+
113+
<button
114+
type="button"
115+
onClick={handleUpload}
116+
disabled={!selectedFile || isCompressing || isUploading}
117+
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded text-white ${
118+
!selectedFile || isCompressing || isUploading
119+
? 'bg-gray-400'
120+
: 'bg-blue-600'
121+
}`}
122+
>
123+
{isCompressing ? (
124+
<>
125+
<Loader className="w-4 h-4 animate-spin" />
126+
Compressing...
127+
</>
128+
) : isUploading ? (
129+
<>
130+
<Loader className="w-4 h-4 animate-spin" />
131+
Uploading...
132+
</>
133+
) : (
134+
'Upload Proof'
135+
)}
136+
</button>
137+
</div>
138+
139+
{uploadResult && (
140+
<div className="mt-3 text-sm text-gray-700">{uploadResult}</div>
141+
)}
142+
</div>
143+
);
144+
}

features/escrow/components/EscrowLock.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,7 @@ export function EscrowLock({
2828
const [state, setState] = useState<LockState>('idle');
2929
const { isLoading, error, escrowId, transactionHash, lockEscrow, reset } =
3030
useEscrowLock();
31-
const {
32-
error: toastError,
33-
success: toastSuccess,
34-
loading: toastLoading,
35-
info: toastInfo,
36-
} = useToast();
31+
const { toast } = useToast();
3732

3833
const isWalletConnected = !!walletAddress;
3934
const formattedAmount = amount.toFixed(2);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { EscrowLock } from './EscrowLock';

hooks/useImageCompressor.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useCallback, useState } from 'react';
2+
import { imageCompressionService } from '@/services/imageCompressionService';
3+
4+
export function useImageCompressor() {
5+
const [isCompressing, setIsCompressing] = useState(false);
6+
7+
const compress = useCallback(async (file: File, targetKB = 500) => {
8+
setIsCompressing(true);
9+
try {
10+
const compressed = await imageCompressionService.compressImage(
11+
file,
12+
targetKB
13+
);
14+
return compressed;
15+
} finally {
16+
setIsCompressing(false);
17+
}
18+
}, []);
19+
20+
return { compress, isCompressing } as const;
21+
}

hooks/useNetworkCheck.ts

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
import { useState, useCallback, useEffect, useRef } from 'react';
2-
import { networkService, NetworkInfo } from '@/services/networkService';
2+
import { networkService } from '@/services/networkService';
3+
4+
// Minimal local type for the network info returned by the backend.
5+
// Keep this small to avoid coupling to a non-exported service type.
6+
type NetworkInfo = {
7+
network: string;
8+
chainId?: number | null;
9+
};
310

411
/** How often to re-check the network while a wallet is connected (ms). */
512
const POLL_INTERVAL_MS = 10_000;
613

714
export type NetworkStatus =
8-
| 'idle' // no wallet connected
9-
| 'loading' // first fetch in progress
10-
| 'match' // wallet network matches .env config
11-
| 'mismatch' // wallet is on the wrong network
12-
| 'error'; // fetch failed
15+
| 'idle' // no wallet connected
16+
| 'loading' // first fetch in progress
17+
| 'match' // wallet network matches .env config
18+
| 'mismatch' // wallet is on the wrong network
19+
| 'error'; // fetch failed
1320

1421
/**
1522
* useNetworkCheck — polls the backend to detect whether the connected wallet
@@ -23,8 +30,7 @@ export type NetworkStatus =
2330
* - `recheck` — manually trigger a re-check immediately
2431
*/
2532
export function useNetworkCheck(address: string | null) {
26-
const expectedNetwork =
27-
process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet';
33+
const expectedNetwork = process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet';
2834

2935
const [walletNetwork, setWalletNetwork] = useState<NetworkInfo | null>(null);
3036
const [status, setStatus] = useState<NetworkStatus>('idle');
@@ -33,9 +39,8 @@ export function useNetworkCheck(address: string | null) {
3339

3440
const check = useCallback(async () => {
3541
if (!address) {
36-
3742
setStatus('idle');
38-
43+
3944
setWalletNetwork(null);
4045
return;
4146
}
@@ -47,18 +52,41 @@ export function useNetworkCheck(address: string | null) {
4752
try {
4853
const response = await networkService.getWalletNetwork(address);
4954
if (response.success && response.data) {
50-
setWalletNetwork(response.data);
55+
const incoming: NetworkInfo = response.data;
56+
57+
// Determine match based on configured expected network (case-insensitive).
5158
const match =
52-
response.data.network.toLowerCase() ===
53-
expectedNetwork.toLowerCase();
54-
setStatus(match ? 'match' : 'mismatch');
59+
incoming.network?.toLowerCase() === expectedNetwork.toLowerCase();
60+
61+
// Only update state when something actually changed. Use functional
62+
// updates to avoid reading stale values from the closure and to
63+
// prevent setting new object identities when the logical data is
64+
// identical — this prevents unnecessary re-renders (the likely
65+
// cause of the infinite loop reported).
66+
setWalletNetwork((prev) => {
67+
if (
68+
prev &&
69+
prev.network?.toLowerCase() === incoming.network?.toLowerCase() &&
70+
(prev.chainId ?? null) === (incoming.chainId ?? null)
71+
) {
72+
return prev;
73+
}
74+
return incoming;
75+
});
76+
77+
setStatus((prev) => {
78+
const newStatus: typeof status = match ? 'match' : 'mismatch';
79+
if (prev === newStatus) return prev;
80+
return newStatus;
81+
});
5582
} else {
5683
setError(response.message || 'Failed to check network');
5784
setStatus('error');
5885
}
5986
} catch (err: any) {
6087
setError(
61-
err.response?.data?.message || 'An error occurred while checking network'
88+
err.response?.data?.message ||
89+
'An error occurred while checking network'
6290
);
6391
setStatus('error');
6492
}
@@ -71,7 +99,7 @@ export function useNetworkCheck(address: string | null) {
7199
if (!address) {
72100
// eslint-disable-next-line react-hooks/set-state-in-effect
73101
setStatus('idle');
74-
102+
75103
setWalletNetwork(null);
76104
return;
77105
}
@@ -91,4 +119,4 @@ export function useNetworkCheck(address: string | null) {
91119
error,
92120
recheck: check,
93121
};
94-
}
122+
}

hooks/useProofUpload.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ interface UseProofUploadReturn {
2727
isCompressing: boolean;
2828
uploadedProofs: UploadedProof[];
2929
errors: string[];
30-
30+
3131
handleFileCapture: (_file: File) => Promise<void>;
32-
32+
3333
handleCameraCapture: (_canvas: HTMLCanvasElement) => Promise<void>;
3434
clearUploadedProofs: () => void;
35-
35+
3636
removeProof: (_filename: string) => void;
3737
}
3838

@@ -51,7 +51,13 @@ export function useProofUpload({
5151
const [isCompressing, setIsCompressing] = useState(false);
5252
const [uploadedProofs, setUploadedProofs] = useState<UploadedProof[]>([]);
5353
const [errors, setErrors] = useState<string[]>([]);
54-
const { error: toastError, success: toastSuccess, loading: toastLoading, info: toastInfo } = useToast();
54+
const {
55+
error: toastError,
56+
success: toastSuccess,
57+
loading: toastLoading,
58+
info: toastInfo,
59+
toast,
60+
} = useToast();
5561

5662
const handleFileCapture = useCallback(
5763
async (file: File) => {
@@ -93,8 +99,11 @@ export function useProofUpload({
9399
},
94100
};
95101

96-
const { file: compressedFile, isCompressed, originalSize } =
97-
await proofService.compressImage(file, compressionOptions);
102+
const {
103+
file: compressedFile,
104+
isCompressed,
105+
originalSize,
106+
} = await proofService.compressImage(file, compressionOptions);
98107

99108
setIsCompressing(false);
100109
setCompressionProgress(100);
@@ -189,11 +198,9 @@ export function useProofUpload({
189198

190199
// Create File from blob
191200
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
192-
const file = new File(
193-
[blob],
194-
`proof-${timestamp}.jpg`,
195-
{ type: 'image/jpeg' }
196-
);
201+
const file = new File([blob], `proof-${timestamp}.jpg`, {
202+
type: 'image/jpeg',
203+
});
197204

198205
// Upload the captured image
199206
await handleFileCapture(file);

0 commit comments

Comments
 (0)