diff --git a/components/logistics/ImageCapture.tsx b/components/logistics/ImageCapture.tsx new file mode 100644 index 0000000..e2ccbb7 --- /dev/null +++ b/components/logistics/ImageCapture.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useCallback, useMemo, useRef, useState } from 'react'; +import { Upload, Loader } from 'lucide-react'; +import { useImageCompressor } from '@/hooks/useImageCompressor'; +import { uploadService } from '@/services/uploadService'; + +export default function ImageCapture() { + const fileInputRef = useRef(null); + const [selectedFile, setSelectedFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [uploadResult, setUploadResult] = useState(null); + const { compress, isCompressing } = useImageCompressor(); + + const handleFileChange = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + if (!file) return; + + // Create preview immediately + const url = URL.createObjectURL(file); + setPreviewUrl(url); + setSelectedFile(file); + setUploadResult(null); + }, + [] + ); + + const compressedInfo = useMemo(() => { + if (!selectedFile) return null; + return { + name: selectedFile.name, + sizeKB: (selectedFile.size / 1024).toFixed(2), + }; + }, [selectedFile]); + + const handleUpload = useCallback(async () => { + if (!selectedFile) return; + setIsUploading(true); + setUploadResult(null); + try { + // Compress to under 500KB + const compressed = await compress(selectedFile, 500); + + // Optionally show compressed size in result + const compressedSizeKB = (compressed.size / 1024).toFixed(2); + + const response = await uploadService.uploadFile(compressed, 'proof'); + if (response.success) { + setUploadResult( + `Uploaded ${response.data?.fileName ?? compressed.name} (${compressedSizeKB} KB)` + ); + // cleanup preview + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + setPreviewUrl(null); + } + setSelectedFile(null); + } else { + setUploadResult( + `Upload failed: ${response.message ?? 'unknown error'}` + ); + } + } catch (err: any) { + setUploadResult(err?.message ?? 'Upload error'); + } finally { + setIsUploading(false); + } + }, [selectedFile, compress, previewUrl]); + + return ( +
+

Capture Proof of Delivery

+ +
+ +
+ + {previewUrl && ( +
+ preview +
+ )} + + {compressedInfo && ( +

+ Original: {compressedInfo.name} — {compressedInfo.sizeKB} KB +

+ )} + +
+ + + +
+ + {uploadResult && ( +
{uploadResult}
+ )} +
+ ); +} diff --git a/features/escrow/components/EscrowLock.tsx b/features/escrow/components/EscrowLock.tsx index 20bf5bb..5c29fe3 100644 --- a/features/escrow/components/EscrowLock.tsx +++ b/features/escrow/components/EscrowLock.tsx @@ -28,12 +28,7 @@ export function EscrowLock({ const [state, setState] = useState('idle'); const { isLoading, error, escrowId, transactionHash, lockEscrow, reset } = useEscrowLock(); - const { - error: toastError, - success: toastSuccess, - loading: toastLoading, - info: toastInfo, - } = useToast(); + const { toast } = useToast(); const isWalletConnected = !!walletAddress; const formattedAmount = amount.toFixed(2); diff --git a/features/escrow/components/index.ts b/features/escrow/components/index.ts new file mode 100644 index 0000000..7588f98 --- /dev/null +++ b/features/escrow/components/index.ts @@ -0,0 +1 @@ +export { EscrowLock } from './EscrowLock'; diff --git a/hooks/useImageCompressor.ts b/hooks/useImageCompressor.ts new file mode 100644 index 0000000..2ee755d --- /dev/null +++ b/hooks/useImageCompressor.ts @@ -0,0 +1,21 @@ +import { useCallback, useState } from 'react'; +import { imageCompressionService } from '@/services/imageCompressionService'; + +export function useImageCompressor() { + const [isCompressing, setIsCompressing] = useState(false); + + const compress = useCallback(async (file: File, targetKB = 500) => { + setIsCompressing(true); + try { + const compressed = await imageCompressionService.compressImage( + file, + targetKB + ); + return compressed; + } finally { + setIsCompressing(false); + } + }, []); + + return { compress, isCompressing } as const; +} diff --git a/hooks/useNetworkCheck.ts b/hooks/useNetworkCheck.ts index 8fe2b7d..db798d4 100644 --- a/hooks/useNetworkCheck.ts +++ b/hooks/useNetworkCheck.ts @@ -1,15 +1,22 @@ import { useState, useCallback, useEffect, useRef } from 'react'; -import { networkService, NetworkInfo } from '@/services/networkService'; +import { networkService } from '@/services/networkService'; + +// Minimal local type for the network info returned by the backend. +// Keep this small to avoid coupling to a non-exported service type. +type NetworkInfo = { + network: string; + chainId?: number | null; +}; /** How often to re-check the network while a wallet is connected (ms). */ const POLL_INTERVAL_MS = 10_000; export type NetworkStatus = - | 'idle' // no wallet connected - | 'loading' // first fetch in progress - | 'match' // wallet network matches .env config - | 'mismatch' // wallet is on the wrong network - | 'error'; // fetch failed + | 'idle' // no wallet connected + | 'loading' // first fetch in progress + | 'match' // wallet network matches .env config + | 'mismatch' // wallet is on the wrong network + | 'error'; // fetch failed /** * useNetworkCheck — polls the backend to detect whether the connected wallet @@ -23,8 +30,7 @@ export type NetworkStatus = * - `recheck` — manually trigger a re-check immediately */ export function useNetworkCheck(address: string | null) { - const expectedNetwork = - process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet'; + const expectedNetwork = process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet'; const [walletNetwork, setWalletNetwork] = useState(null); const [status, setStatus] = useState('idle'); @@ -33,9 +39,8 @@ export function useNetworkCheck(address: string | null) { const check = useCallback(async () => { if (!address) { - setStatus('idle'); - + setWalletNetwork(null); return; } @@ -47,18 +52,41 @@ export function useNetworkCheck(address: string | null) { try { const response = await networkService.getWalletNetwork(address); if (response.success && response.data) { - setWalletNetwork(response.data); + const incoming: NetworkInfo = response.data; + + // Determine match based on configured expected network (case-insensitive). const match = - response.data.network.toLowerCase() === - expectedNetwork.toLowerCase(); - setStatus(match ? 'match' : 'mismatch'); + incoming.network?.toLowerCase() === expectedNetwork.toLowerCase(); + + // Only update state when something actually changed. Use functional + // updates to avoid reading stale values from the closure and to + // prevent setting new object identities when the logical data is + // identical — this prevents unnecessary re-renders (the likely + // cause of the infinite loop reported). + setWalletNetwork((prev) => { + if ( + prev && + prev.network?.toLowerCase() === incoming.network?.toLowerCase() && + (prev.chainId ?? null) === (incoming.chainId ?? null) + ) { + return prev; + } + return incoming; + }); + + setStatus((prev) => { + const newStatus: typeof status = match ? 'match' : 'mismatch'; + if (prev === newStatus) return prev; + return newStatus; + }); } else { setError(response.message || 'Failed to check network'); setStatus('error'); } } catch (err: any) { setError( - err.response?.data?.message || 'An error occurred while checking network' + err.response?.data?.message || + 'An error occurred while checking network' ); setStatus('error'); } @@ -71,7 +99,7 @@ export function useNetworkCheck(address: string | null) { if (!address) { // eslint-disable-next-line react-hooks/set-state-in-effect setStatus('idle'); - + setWalletNetwork(null); return; } @@ -91,4 +119,4 @@ export function useNetworkCheck(address: string | null) { error, recheck: check, }; -} \ No newline at end of file +} diff --git a/hooks/useProofUpload.ts b/hooks/useProofUpload.ts index 5d11931..6f12220 100644 --- a/hooks/useProofUpload.ts +++ b/hooks/useProofUpload.ts @@ -27,12 +27,12 @@ interface UseProofUploadReturn { isCompressing: boolean; uploadedProofs: UploadedProof[]; errors: string[]; - + handleFileCapture: (_file: File) => Promise; - + handleCameraCapture: (_canvas: HTMLCanvasElement) => Promise; clearUploadedProofs: () => void; - + removeProof: (_filename: string) => void; } @@ -51,7 +51,13 @@ export function useProofUpload({ const [isCompressing, setIsCompressing] = useState(false); const [uploadedProofs, setUploadedProofs] = useState([]); const [errors, setErrors] = useState([]); - const { error: toastError, success: toastSuccess, loading: toastLoading, info: toastInfo } = useToast(); + const { + error: toastError, + success: toastSuccess, + loading: toastLoading, + info: toastInfo, + toast, + } = useToast(); const handleFileCapture = useCallback( async (file: File) => { @@ -93,8 +99,11 @@ export function useProofUpload({ }, }; - const { file: compressedFile, isCompressed, originalSize } = - await proofService.compressImage(file, compressionOptions); + const { + file: compressedFile, + isCompressed, + originalSize, + } = await proofService.compressImage(file, compressionOptions); setIsCompressing(false); setCompressionProgress(100); @@ -189,11 +198,9 @@ export function useProofUpload({ // Create File from blob const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const file = new File( - [blob], - `proof-${timestamp}.jpg`, - { type: 'image/jpeg' } - ); + const file = new File([blob], `proof-${timestamp}.jpg`, { + type: 'image/jpeg', + }); // Upload the captured image await handleFileCapture(file); diff --git a/hooks/useToast.ts b/hooks/useToast.ts index 2f90ed4..0e57b98 100644 --- a/hooks/useToast.ts +++ b/hooks/useToast.ts @@ -14,6 +14,7 @@ export interface UseToastReturn { error: (message: string, description?: string, duration?: number) => void; info: (message: string, description?: string, duration?: number) => void; loading: (message: string, description?: string) => void; + toast: (config: ToastConfig) => void; isLoading: boolean; notifications: ToastMessage[]; fetchNotifications: () => Promise; @@ -34,10 +35,10 @@ export function useToast(): UseToastReturn { try { setIsLoading(true); const response = await toastService.fetchToastMessages(); - + if (response.success && response.data) { setNotifications(response.data); - + // Auto-show urgent notifications response.data.forEach((notif) => { if (notif.variant === 'error' || notif.variant === 'loading') { @@ -98,18 +99,36 @@ export function useToast(): UseToastReturn { /** * Show loading notification */ - const showLoading = useCallback( - (message: string, description?: string) => { - toastService.loading(message, description); - }, - [] - ); + const showLoading = useCallback((message: string, description?: string) => { + toastService.loading(message, description); + }, []); + + const showToast = useCallback((config: ToastConfig) => { + const { title, description, variant = 'default', duration } = config; + switch (variant) { + case 'destructive': + case 'error': + toastService.error(title, description, duration); + break; + case 'loading': + toastService.loading(title, description); + break; + case 'success': + toastService.success(title, description, duration); + break; + case 'info': + default: + toastService.info(title, description, duration); + break; + } + }, []); return { success: showSuccess, error: showError, info: showInfo, loading: showLoading, + toast: showToast, isLoading, notifications, fetchNotifications, diff --git a/jest.setup.ts b/jest.setup.ts index b3b9bef..83a05e5 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -8,4 +8,19 @@ global.ResizeObserver = class ResizeObserver { }; // scrollIntoView is not available in jsdom — required by cmdk -Element.prototype.scrollIntoView = jest.fn(); \ No newline at end of file +Element.prototype.scrollIntoView = jest.fn(); + +// jsdom doesn't implement URL.createObjectURL / revokeObjectURL +if (typeof URL.createObjectURL === 'undefined') { + // @ts-ignore - augment global URL in tests + URL.createObjectURL = function (blob: any) { + return 'blob://mock-' + Math.random().toString(36).slice(2); + }; +} + +if (typeof URL.revokeObjectURL === 'undefined') { + // @ts-ignore + URL.revokeObjectURL = function (_url: string) { + return undefined; + }; +} diff --git a/services/imageCompressionService.ts b/services/imageCompressionService.ts new file mode 100644 index 0000000..653394c --- /dev/null +++ b/services/imageCompressionService.ts @@ -0,0 +1,44 @@ +import imageCompression from 'browser-image-compression'; + +/** + * imageCompressionService — wraps `browser-image-compression` to provide + * a simple, retrying compression API that targets a maximum size in KB. + */ +export const imageCompressionService = { + /** + * Compress a File to target size (KB). Returns a new File instance. + */ + async compressImage(file: File, targetSizeKB = 500): Promise { + // Initial options for the library. The library will attempt to respect + // maxSizeMB but we also fall back to progressive quality reduction. + const optionsBase: Record = { + maxSizeMB: targetSizeKB / 1024, + maxWidthOrHeight: 1280, + useWebWorker: true, + }; + + // First attempt using the library's built-in algorithm. + let compressedBlob: Blob = await imageCompression(file as any, optionsBase); + + // If still too large, progressively reduce quality until under target + // or until a reasonable lower bound is reached. + if (compressedBlob.size > targetSizeKB * 1024) { + let quality = 0.8; + while (compressedBlob.size > targetSizeKB * 1024 && quality >= 0.3) { + const opts = { ...optionsBase, initialQuality: quality }; + // re-run compression with lower quality + // eslint-disable-next-line no-await-in-loop + compressedBlob = await imageCompression(file as any, opts); + quality -= 0.15; + } + } + + // Convert blob back to File for downstream upload convenience. + const mime = (compressedBlob as any).type || file.type; + const compressedFile = new File([compressedBlob], file.name, { + type: mime, + }); + + return compressedFile; + }, +}; diff --git a/store/registrationStore.ts b/store/registrationStore.ts index 2184fb4..93aae28 100644 --- a/store/registrationStore.ts +++ b/store/registrationStore.ts @@ -54,11 +54,10 @@ const initialState: RegistrationState = { * useRegistrationStore — Zustand store for managing multi-step registration state * Persists state across component re-renders and step navigation */ -export const useRegistrationStore = create((set) => ({ +const baseStore = create((set) => ({ ...initialState, - setRole: (role: UserRole) => - set({ role, currentStep: 2 }), + setRole: (role: UserRole) => set({ role, currentStep: 2 }), setPersonalDetails: (details: Partial) => set((state) => ({ ...state, ...details })), @@ -79,11 +78,32 @@ export const useRegistrationStore = create((set) => ({ currentStep: Math.max(state.currentStep - 1, 1), })), - setStep: (step: number) => - set({ currentStep: step }), + setStep: (step: number) => set({ currentStep: step }), - setIsSubmitting: (submitting: boolean) => - set({ isSubmitting: submitting }), + setIsSubmitting: (submitting: boolean) => set({ isSubmitting: submitting }), reset: () => set(initialState), })); + +const originalGetState = baseStore.getState.bind(baseStore); + +const liveStateProxy = new Proxy({} as RegistrationStore, { + get(_target, prop) { + const state = originalGetState(); + if (prop in state) { + return state[prop as keyof RegistrationState]; + } + + const action = state[prop as keyof RegistrationStore]; + if (typeof action === 'function') { + return (...args: unknown[]) => + (action as (...args: unknown[]) => unknown)(...args); + } + + return undefined; + }, +}); + +export const useRegistrationStore = Object.assign(baseStore, { + getState: () => liveStateProxy, +});