diff --git a/.gitignore b/.gitignore index 5ef6a52..dd146b5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..bea3b3d --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "Sentry": { + "url": "https://mcp.sentry.dev/mcp/velo-7b/javascript-nextjs", + "type": "http" + } + } +} \ No newline at end of file diff --git a/app/api/transaction-monitor/route.ts b/app/api/transaction-monitor/route.ts deleted file mode 100644 index a23b62e..0000000 --- a/app/api/transaction-monitor/route.ts +++ /dev/null @@ -1,58 +0,0 @@ -// app/api/wallet-monitor/route.ts -import { NextRequest, NextResponse } from "next/server"; -import { BlockchainManager } from "@/service/blockchain-manager"; -const blockchainManager = new BlockchainManager(); - -interface MultiChainWalletMonitorRequest { - chain: string; - walletAddress: string; - fromBlock?: number; -} - -export async function POST(request: NextRequest) { - // ... authentication and rate limiting (same as before) - - try { - const body: Partial = await request.json(); - const { chain, walletAddress, fromBlock } = body; - - if (!chain || !walletAddress) { - return NextResponse.json( - { error: "Missing required parameters: chain, walletAddress" }, - { status: 400 } - ); - } - - const result = await blockchainManager.monitorWallet( - chain, - walletAddress, - fromBlock - ); - - return NextResponse.json(result); - } catch { - // Error handling - } -} - -// GET endpoint to list supported chains -export async function GET(request: NextRequest) { - const url = new URL(request.url); - const action = url.searchParams.get("action"); - - if (action === "chains") { - const chains = blockchainManager.getSupportedChains(); - return NextResponse.json({ chains }); - } - - if (action === "status") { - const status = await blockchainManager.testAllConnections(); - return NextResponse.json({ status }); - } - - // Existing monitoring functionality - // const chain = url.searchParams.get("chain"); - // const walletAddress = url.searchParams.get("wallet"); - // const fromBlockParam = url.searchParams.get("fromBlock"); - -} \ No newline at end of file diff --git a/components/dashboard/tabs/help.tsx b/app/dashboard/help/page.tsx similarity index 100% rename from components/dashboard/tabs/help.tsx rename to app/dashboard/help/page.tsx diff --git a/components/dashboard/tabs/history.tsx b/app/dashboard/history/page.tsx similarity index 100% rename from components/dashboard/tabs/history.tsx rename to app/dashboard/history/page.tsx diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 706f0a2..79cc6a3 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -1,9 +1,64 @@ +"use client"; + import type React from "react"; +import { useBack } from "@/components/hooks/useBack"; +import { ArrowLeft } from "lucide-react"; +import ProtectedRoute from "@/components/auth/protected-route"; +import { useNotifications } from "@/components/hooks/useNotifications"; +import { useTokenMonitor } from "@/components/hooks/useTokenMonitor"; +import { useRef, useEffect } from "react"; +import { useAuth } from "@/components/context/AuthContext"; +import { useDeposits } from "@/components/hooks"; +import { ToastContainer } from "@/components/modals/toastContainer"; +import { TokenExpiredDialog } from "@/components/modals/TokenExpiredDialog"; +import { MobileBottomNav } from "@/components/dashboard/mobile-bottom-nav"; +import { TopNav } from "@/components/dashboard/top-nav"; export default function RootLayout({ children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return
{children}
; +}: Readonly<{ children: React.ReactNode }>) { + const { checkDeposits } = useDeposits(); + const { token } = useAuth(); + + const { toasts, removeToast } = useNotifications(); + const checkRef = useRef(checkDeposits); + useEffect(() => { + checkRef.current = checkDeposits; + }, [checkDeposits]); + + useEffect(() => { + if (!token) return; + + checkRef.current(); + + const id = window.setInterval(() => { + if (token) checkRef.current(); + }, 20000); + + return () => window.clearInterval(id); + }, [token]); + + const { showExpiredDialog, handleRelogin } = useTokenMonitor(); + + return ( + +
+ + + +
+ + {children} +
+ + + +
+
+ ); } diff --git a/app/dashboard/merchant/page.tsx b/app/dashboard/merchant/page.tsx new file mode 100644 index 0000000..840f9c1 --- /dev/null +++ b/app/dashboard/merchant/page.tsx @@ -0,0 +1,298 @@ +"use client"; + +import React, { useCallback, useMemo, useState } from "react"; +import useExchangeRates from "@/components/hooks/useExchangeRate"; +import { QRCodeDisplay } from "@/components/modals/qr-code-display"; +import { useMerchantPayments } from "@/components/hooks/useMerchantPayments"; +import { AddressDropdown } from "@/components/modals/addressDropDown"; +import { useWalletData } from "@/components/hooks/useWalletData"; + +import { + generateCompatibleQRCode, + getCurrencySymbol, + supportsAmountInQR +} from "@/lib/utils/qr-utils"; +import { + getTokenRateKey, + getTokenChain, + formatBalance +} from "@/lib/utils/token-utils"; +import { CardContainer } from "@/components/ui/CardContainer"; +import { StepsGuide } from "@/components/ui/StepsGuide"; +import { ValidationError } from "@/components/modals/TransactionStatus"; +import { AmountInput } from "@/components/ui/AmountInput"; +import { LoadingState } from "@/components/ui/LoadingState"; +import { normalizeStarknetAddress } from "@/components/lib/utils"; + +export default function QrPayment() { + const [token, setToken] = useState("STARKNET"); + const [amount, setAmount] = useState(""); + const [description, setDescription] = useState(""); + const [showQR, setShowQR] = useState(false); + const [qrData, setQrData] = useState(""); + const [isProcessing, setIsProcessing] = useState(false); + const [paymentId, setPaymentId] = useState(""); + const [localError, setLocalError] = useState(null); + const [paymentStatus, setPaymentStatus] = useState(""); + + const { addresses } = useWalletData(); + const { rates, isLoading: ratesLoading } = useExchangeRates(); + const { createPayment, isLoading: merchantLoading } = useMerchantPayments(); + + // Get the current wallet address for the selected token + const currentReceiverAddress = useMemo((): string => { + if (!addresses || addresses.length === 0) return ""; + + const chain = getTokenChain(token); + const addr = addresses.find((a) => a.chain === chain); + if (!addr) return ""; + + return normalizeStarknetAddress(addr.address, chain); + }, [addresses, token]); + + // Calculate token amount based on NGN input + const calculateTokenAmount = useCallback((): string => { + const ngnAmount = parseFloat(amount) || 0; + const rateKey = getTokenRateKey(token); + const rate = rateKey ? rates[rateKey] : 1; + + if (!rate || rate === 0) { + console.log("Using fallback rate for calculation"); + return (ngnAmount / 1500).toFixed(6); + } + + const tokenAmount = ngnAmount / rate; + return tokenAmount.toFixed(6); + }, [amount, rates, token]); + + // Handle token selection + const handleTokenSelect = (tkn: string) => { + setToken(tkn.toUpperCase()); + }; + + // Create payment request + const handleCreatePaymentRequest = async () => { + if (!amount || !currentReceiverAddress) { + setLocalError( + "Please enter an amount and ensure wallet address is available" + ); + return; + } + + setIsProcessing(true); + setLocalError(null); + + try { + const tokenAmount = calculateTokenAmount(); + const chain = getTokenChain(token); + + // Use the new QR utility + const qrResult = await generateCompatibleQRCode( + chain, + currentReceiverAddress, + { + amount: tokenAmount, + width: 200, + margin: 2, + errorCorrectionLevel: "M" as const, + } + ); + + // Prepare request body + const requestBody = { + amount: parseFloat(tokenAmount), + chain, + network: "mainnet" as const, + description: description || "QR Payment request", + ...getAddressFieldForChain(chain, currentReceiverAddress) + }; + + const response = await createPayment(requestBody); + + if (response?.payment) { + setPaymentId(response.payment.id || ""); + setPaymentStatus(response.payment.status); + setQrData(qrResult.dataUrl); + setShowQR(true); + } else { + throw new Error("Invalid response from server - no payment data"); + } + } catch (error: any) { + console.error("Error creating payment request:", error); + setLocalError(error.message || "Failed to create payment request"); + } finally { + setIsProcessing(false); + } + }; + + // Helper to get the correct address field name for API + const getAddressFieldForChain = (chain: string, address: string) => { + const fieldMap: Record> = { + bitcoin: { btcAddress: address }, + ethereum: { ethAddress: address }, + solana: { solAddress: address }, + starknet: { strkAddress: address }, + usdt_erc20: { usdtErc20Address: address }, + usdt_trc20: { usdtTrc20Address: address }, + polkadot: { dotAddress: address }, + stellar: { xmlAddress: address }, + }; + + return fieldMap[chain] || { address }; + }; + + // Close QR modal + const handleCloseQR = () => { + setShowQR(false); + setQrData(""); + setPaymentId(""); + setLocalError(null); + setAmount(""); + }; + + // Steps for instructions + const steps = [ + { + title: "Enter Amount", + description: "Specify the amount in NGN you want to receive", + }, + { + title: "Generate QR", + description: "Create a unique payment request QR code", + }, + { + title: "Share QR", + description: "Send the QR code or address to the payer", + }, + { + title: "Receive Payment", + description: "Funds will be credited after confirmation", + }, + ]; + + // Check if form can be submitted + const canSubmit = !isProcessing && + amount && + !ratesLoading && + currentReceiverAddress; + + // Show loading state while addresses are loading + if (addresses.length === 0) { + return ; + } + + return ( +
+
+ {/* Main Card */} + +

+ Create Payment Request +

+ +
+
+ {/* Token Selection */} +
+ + +
+ + {/* Amount Input */} +
+ + +

+ ≈ {calculateTokenAmount()} {getCurrencySymbol(getTokenChain(token))} +

+
+ + {/* Description Input */} +
+ + setDescription(e.target.value)} + placeholder="Describe the payment purpose" + className="w-full p-3 rounded-lg bg-muted placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors" + disabled={merchantLoading || ratesLoading || isProcessing} + /> +
+ + +
+ + {/* Error Message */} + {localError && !showQR && ( + + )} + + {/* Generate Button */} + + + {/* Additional Info */} +
+

+ Note: QR code includes amount in {getCurrencySymbol(getTokenChain(token))} + {supportsAmountInQR(getTokenChain(token)) + ? " - compatible with most wallets" + : " - some wallets may not support amount display" + } +

+ {currentReceiverAddress && ( +

+ Receiving address: {currentReceiverAddress.slice(0, 12)}...{currentReceiverAddress.slice(-6)} +

+ )} +
+
+
+ + {/* Instructions Card */} + +
+ + {/* QR Code Modal */} + {showQR && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/components/dashboard/tabs/notifications.tsx b/app/dashboard/notifications/page.tsx similarity index 100% rename from components/dashboard/tabs/notifications.tsx rename to app/dashboard/notifications/page.tsx diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index fea1442..2ea51ed 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,95 +1,93 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import DashboardHome from "@/components/dashboard/tabs/dashboard"; -import QrPayment from "@/components/dashboard/tabs/qr-payment"; -import PaymentSplit from "@/components/dashboard/tabs/payment-split"; -import Swap from "@/components/dashboard/tabs/swap"; -import Profile from "@/components/dashboard/tabs/profile"; -// import AuthPage from "@/components/auth/AuthPage"; -import Logout from "@/components/dashboard/tabs/logout"; -import CreateAddressTab from "@/components/dashboard/tabs/create-address"; -import History from "@/components/dashboard/tabs/history"; -import Notifications from "@/components/dashboard/tabs/notifications"; -import Help from "@/components/dashboard/tabs/help"; -import ProtectedRoute from "@/components/auth/protected-route"; -import { SideNav } from "@/components/dashboard/side-nav"; -import { TopNav } from "@/components/dashboard/top-nav"; -import { MobileBottomNav } from "@/components/dashboard/mobile-bottom-nav"; -import SendFunds from "@/components/dashboard/tabs/send-funds"; -import { ToastContainer } from "@/components/modals/toastContainer"; -import { useNotifications } from "@/components/hooks/useNotifications"; -import TopUp from "@/components/dashboard/tabs/top-up"; -import { useDeposits } from "@/components/hooks"; +import { Card } from "@/components/ui/Card"; +import Button from "@/components/ui/Button"; +import { StatsCards } from "@/components/dashboard/stats-cards"; +import { QuickActions } from "@/components/dashboard/quick-actions"; +import { RecentActivity } from "@/components/dashboard/recent-activity"; +import { WalletOverview } from "@/components/dashboard/wallet-overview"; import { useAuth } from "@/components/context/AuthContext"; -import { useTokenMonitor } from "@/components/hooks/useTokenMonitor"; -import { TokenExpiredDialog } from "@/components/modals/TokenExpiredDialog"; -import Services from "@/components/dashboard/tabs/services"; +import { useState } from "react"; +import Link from "next/link"; + +interface RecentActivity { + id: string; + type: "incoming" | "outgoing" | "swap" | "split"; + amount: string; + token: string; + description: string; + timestamp: string; + status: "completed" | "pending" | "failed"; +} -export default function Dashboard() { - const { checkDeposits } = useDeposits(); - const { token } = useAuth(); - const [activeTab, setActiveTab] = useState("Dashboard"); - const { toasts, removeToast } = useNotifications(); - const checkRef = useRef(checkDeposits); - useEffect(() => { - checkRef.current = checkDeposits; - }, [checkDeposits]); +export default function DashboardHome() { + const { user } = useAuth(); - useEffect(() => { - // Only start checking deposits once we have an auth token. - if (!token) return; + const [hideBalalance, setHideBalance] = useState(false); - checkRef.current(); + const handleViewBalance = () => { + setHideBalance(!hideBalalance); + }; - const id = window.setInterval(() => { - if (token) checkRef.current(); - }, 20000); + return ( +
+ {/* Header */} +
+
+

+ Welcome back, {user?.firstName?.toLocaleUpperCase()} +

+

+ {"Ready to manage your finances? Let's make some magic happen."} +

+
- return () => window.clearInterval(id); - }, [token]); - const { showExpiredDialog, handleRelogin } = useTokenMonitor(); +
- return ( - -
-
- + {/* Stats Grid */} + -
- + {/* Quick Actions */} +
+ {" "} +
-
- {activeTab === "Dashboard" && ( - - )} - + {/* Main Content Grid */} + + +
+ +
- {activeTab === "QRPayment" && } - {activeTab === "Payment split" && } - {activeTab === "Swap" && } - {activeTab === "profile" && } - {activeTab === "Logout" && } - {activeTab === "Receive funds" && } - {activeTab === "History" && } - {activeTab === "Notification" && } - {activeTab === "Help" && } - {activeTab === "Send" && } - {activeTab === "Top Up" && } - {activeTab === "Services" && } - - {/* */} -
+ {/* Bottom CTA */} + +
+
+

Need Help?

+

+ Check our Help or contact support +

-
- -
- +
+ + Help + + +
+
+ +
); } diff --git a/app/dashboard/profile/page.tsx b/app/dashboard/profile/page.tsx new file mode 100644 index 0000000..07bac41 --- /dev/null +++ b/app/dashboard/profile/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { RefreshCw } from "lucide-react"; +import { useAuth } from "@/components/context/AuthContext"; +import { Button } from "@/components/ui/buttons"; +import { ProfileForm } from "@/components/profile/profile-form"; +import { motion } from "framer-motion"; +import { ProfileAvatar } from "@/components/profile/profile-avatar"; +import { BankAccounts } from "@/components/profile/bank-accounts"; +import { IdentityVerification } from "@/components/profile/identity-verification"; +import { SetTransactionPin } from "@/components/profile/pin"; + +export default function ProfileSettingsPage() { + const { user} = useAuth(); + + console.log("XXXXXXXXX",user) + return ( +
+
+ + {/* Header */} +
+
+

Profile Settings

+

+ Manage your profile information and preferences +

+
+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/app/dashboard/receive/page.tsx b/app/dashboard/receive/page.tsx new file mode 100644 index 0000000..c6dbbfe --- /dev/null +++ b/app/dashboard/receive/page.tsx @@ -0,0 +1,201 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import Image from "next/image"; +import { Copy, Check } from "lucide-react"; +import { AddressDropdown } from "@/components/modals/addressDropDown"; +import { useWalletData } from "@/components/hooks/useWalletData"; + +// Import the new utilities and components +import { + generateCompatibleQRCode, + getCurrencySymbol +} from "@/lib/utils/qr-utils"; +import { CardContainer } from "@/components/ui/CardContainer"; +import { LoadingState } from "@/components/ui/LoadingState"; +import { EmptyState } from "@/components/ui/LoadingState"; +import { AddressCopyButton } from "@/components/ui/AddressCopyButton"; +import { InstructionCard } from "@/components/ui/CardContainer"; +import { fixStarknetAddress, shortenAddress } from "@/components/lib/utils"; + +export default function ReceiveFunds() { + const [selectedToken, setSelectedToken] = useState("starknet"); + const [qrData, setQrData] = useState(""); + const [isLoading, setIsLoading] = useState(true); + + const { addresses } = useWalletData(); + + // Get selected token data + const selectedTokenData = addresses?.find( + (token) => token.chain === selectedToken + ); + + // Generate QR code when selected token changes + useEffect(() => { + const generateQrCode = async () => { + if (!selectedTokenData?.address) { + setQrData(""); + return; + } + + try { + let addressToUse = selectedTokenData.address; + + // Normalize Starknet address if needed + if (selectedTokenData.chain.toLowerCase() === "starknet") { + addressToUse = fixStarknetAddress( + addressToUse, + selectedTokenData.chain + ); + } + + const qrResult = await generateCompatibleQRCode( + selectedTokenData.chain, + addressToUse, + { + width: 200, + margin: 2, + errorCorrectionLevel: "M" as const, + } + ); + + setQrData(qrResult.dataUrl); + } catch (error) { + console.error("Error generating QR code:", error); + setQrData(""); + } + }; + + generateQrCode(); + }, [selectedTokenData]); + + // Check if wallet addresses are available + useEffect(() => { + if (addresses) { + setIsLoading(false); + } + }, [addresses]); + + // Handle token selection + const handleTokenSelect = useCallback((symbol: string) => { + setSelectedToken(symbol); + }, []); + + // Prepare shortened address for display + const getDisplayAddress = () => { + if (!selectedTokenData?.address) return ""; + + const normalizedAddress = fixStarknetAddress( + selectedTokenData.address, + selectedTokenData.chain + ); + + return shortenAddress(normalizedAddress, 10); + }; + + // Loading state + if (isLoading || addresses.length < 1) { + return ( + + ); + } + + // Empty state + if (!addresses || addresses.length === 0) { + return ( + + ); + } + + const instructions = [ + "Select the currency you want to receive", + "Share your QR code or wallet address", + "Wait for the sender to complete the transaction", + "Funds will appear in your wallet after confirmation", + ]; + + return ( +
+
+ {/* Main Card */} + + {/* Header */} +
+

Receive Funds

+

+ Select a currency and share your address to receive payments +

+
+ + {/* Token Selector */} +
+ +
+ + {/* QR Code Display */} +
+ {qrData ? ( +
+ QR Code +
+ ) : ( +
+ Loading QR... +
+ )} + +
+

+ {getCurrencySymbol(selectedTokenData?.chain || selectedToken)} Address +

+
+

+ {getDisplayAddress()} +

+ + {/* Using AddressCopyButton component */} + +
+ + {/* Network info */} + {selectedTokenData?.network && ( +

+ Network: {selectedTokenData.network} +

+ )} +
+
+
+ + {/* Instructions Card */} + +
+
+ ); +} \ No newline at end of file diff --git a/app/dashboard/send/page.tsx b/app/dashboard/send/page.tsx new file mode 100644 index 0000000..253dd42 --- /dev/null +++ b/app/dashboard/send/page.tsx @@ -0,0 +1,419 @@ +"use client" + +import { useAuth } from "@/components/context/AuthContext"; +import { useTokenBalance } from "@/components/hooks"; +import { + normalizeStarknetAddress, + shortenAddress, +} from "@/components/lib/utils"; +import { AddressDropdown } from "@/components/modals/addressDropDown"; +import { + StatusBadge, + TransactionStatus, +} from "@/components/modals/TransactionStatus"; +import { AddressCopyButton } from "@/components/ui/AddressCopyButton"; +import { AmountInput } from "@/components/ui/AmountInput"; +import { CardContainer, InstructionCard } from "@/components/ui/CardContainer"; +import { TransactionPinDialog } from "@/components/ui/transaction-pin-dialog"; +import { formatNGN } from "@/lib/utils/token-utils"; +import { AlertCircle, Loader2, ArrowUpRight } from "lucide-react"; +import { useState, useMemo, useCallback } from "react"; + +export default function SendFunds() { + const [selectedToken, setSelectedToken] = useState("ethereum"); + const [toAddress, setToAddress] = useState(""); + const [amount, setAmount] = useState(""); + const [isSending, setIsSending] = useState(false); + const [showPinDialog, setShowPinDialog] = useState(false); + const [txStatus, setTxStatus] = useState<{ + type: "success" | "error" | null; + message: string; + txHash?: string; + }>({ type: null, message: "" }); + + const { sendTransaction } = useAuth(); + + // Single hook for all token data + const { + walletTokens, + getTokenInfo, + getWalletBalance, + getWalletAddress, + getWalletNetwork, + hasWalletForToken, + getTokenSymbol, + getTokenName, + getTokenRate, + isLoading, + error, + formatBalance, + formatValue, + } = useTokenBalance(); + + const selectedTokenData = getTokenInfo(selectedToken); + + // Simplified derived values + const currentWalletBalance = getWalletBalance(selectedToken); + console.log("balance" , currentWalletBalance) + const currentWalletAddress = getWalletAddress(selectedToken); + const currentNetwork = getWalletNetwork(selectedToken); + const hasWalletForSelectedToken = hasWalletForToken(selectedToken); + + // Simplified NGN calculation + const ngnEquivalent = useMemo(() => { + if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) + return 0; + + const tokenSymbol = getTokenSymbol(selectedToken); + const tokenRate = getTokenRate(tokenSymbol); + return parseFloat(amount) * tokenRate; + }, [amount, selectedToken, getTokenRate, getTokenSymbol]); + + // Simplified validation + const validationError = useMemo(() => { + if (!hasWalletForSelectedToken) { + return "No wallet found for this currency"; + } + if (!toAddress.trim()) { + return "Recipient address is required"; + } + if (!amount || parseFloat(amount) <= 0) { + return "Amount must be greater than 0"; + } + if (parseFloat(amount) > currentWalletBalance) { + return "Insufficient balance"; + } + return null; + }, [hasWalletForSelectedToken, toAddress, amount, currentWalletBalance]); + + // Reset form + const resetForm = useCallback(() => { + setToAddress(""); + setAmount(""); + setTxStatus({ type: null, message: "" }); + }, []); + + // Handle token selection + const handleTokenSelect = useCallback( + (chain: string) => { + setSelectedToken(chain); + resetForm(); + }, + [resetForm] + ); + + // Handle send transaction with PIN + const handleSendWithPin = async (pin: string) => { + if (validationError) { + setTxStatus({ + type: "error", + message: validationError, + }); + setShowPinDialog(false); + return; + } + + setIsSending(true); + setTxStatus({ type: null, message: "" }); + + try { + let normalizedToAddress = toAddress.trim(); + let normalizedFromAddress = currentWalletAddress.trim(); + + // Special handling for Starknet addresses + if (selectedToken === "starknet") { + try { + normalizedToAddress = normalizeStarknetAddress( + normalizedToAddress, + selectedToken + ); + normalizedFromAddress = normalizeStarknetAddress( + normalizedFromAddress, + selectedToken + ); + console.log("Normalized Starknet address:", normalizedToAddress); + } catch (error) { + throw new Error( + error instanceof Error + ? `Invalid Starknet address: ${error.message}` + : "Invalid Starknet address format" + ); + } + } + + // Send transaction + const response = await sendTransaction({ + chain: selectedToken, + network: currentNetwork, + toAddress: normalizedToAddress, + amount: amount, + fromAddress: normalizedFromAddress, + transactionPin: pin, + }); + + setTxStatus({ + type: "success", + message: "Transaction sent successfully!", + txHash: response.txHash, + }); + + // Close PIN dialog + setShowPinDialog(false); + + // Reset form after 10 seconds + setTimeout(() => { + resetForm(); + }, 10000); + } catch (error: any) { + console.error("Transaction error:", error); + + let errorMessage = "Failed to send transaction. Please try again."; + + if (error.message) { + errorMessage = error.message; + } else if (typeof error === "string") { + errorMessage = error; + } + + setTxStatus({ + type: "error", + message: errorMessage, + }); + + // Close PIN dialog on error + setShowPinDialog(false); + } finally { + setIsSending(false); + } + }; + + // Handle send transaction (show PIN dialog) + const handleSendTransaction = () => { + if (validationError) { + setTxStatus({ + type: "error", + message: validationError, + }); + return; + } + + // Show PIN dialog instead of immediately sending + setShowPinDialog(true); + }; + + // Handle PIN dialog close + const handlePinDialogClose = () => { + setShowPinDialog(false); + }; + + // Loading state + + // Instructions for important notes + const importantNotes = [ + "Recipient does NOT need to be a Velo user", + "Only send to valid addresses for the selected currency", + "Transactions are irreversible once confirmed", + "Double-check addresses before sending", + ]; + + // Add Starknet-specific notes if needed + if (selectedToken === "starknet") { + importantNotes.push( + "Starknet wallets may need deployment (auto-handled)", + "Addresses will be auto-formatted with 0x prefix and padding" + ); + } + + return ( +
+
+ {/* Main Card */} + + {/* Header */} +
+

Send Payment

+

+ Transfer funds from your Velo wallet to any valid address +

+
+ +
+ {/* Transaction Status */} + {txStatus.type && ( + setTxStatus({ type: null, message: "" })} + showExplorerLink={!!txStatus.txHash} + autoDismiss={txStatus.type === "success"} + /> + )} + + {/* Wallet Status Warning */} + {!hasWalletForSelectedToken && ( +
+
+ + No Wallet Found +
+

+ No Velo wallet found for {getTokenName(selectedToken)}. You + can only send from wallets created in Velo. +

+
+ )} + +
+
+ + + {currentWalletAddress && ( +
+
+ + From Address: + + +
+

+ {shortenAddress(currentWalletAddress, 8)} +

+
+

+ Network: {currentNetwork} +

+ +
+
+ )} +
+ + {/* Right Column - Recipient Info */} +
+
+ + setToAddress(e.target.value)} + placeholder={`Enter ${ + selectedTokenData?.name || selectedToken + } address`} + className="w-full p-3 rounded-lg bg-muted placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors font-mono text-sm" + disabled={!hasWalletForSelectedToken || isSending} + /> + {selectedToken === "starknet" && toAddress && ( +

+ Tip: Address will be automatically formatted with 0x + prefix and proper padding +

+ )} +
+ + {/* Amount Input */} +
+ + +
+ + Available:{" "} + {currentWalletBalance}{" "} + {selectedTokenData?.symbol} + + {ngnEquivalent > 0 && ( + ≈ {formatNGN(ngnEquivalent)} + )} +
+
+
+
+ + {/* Send Button */} +
+ +
+ + {/* Network Info */} +
+

+ Sending on{" "} + {currentNetwork}{" "} + network +

+
+
+
+ + {/* Instructions Card */} + +
+ + {/* PIN Dialog */} + +
+ ); +} diff --git a/app/dashboard/service/airtime/page.tsx b/app/dashboard/service/airtime/page.tsx new file mode 100644 index 0000000..a20b29d --- /dev/null +++ b/app/dashboard/service/airtime/page.tsx @@ -0,0 +1,5 @@ +import PurchaseFlow from "@/components/dashboard/purchase/PurchaseFlow"; + +export default function AirtimePage() { + return ; +} \ No newline at end of file diff --git a/app/dashboard/service/data/page.tsx b/app/dashboard/service/data/page.tsx new file mode 100644 index 0000000..4915034 --- /dev/null +++ b/app/dashboard/service/data/page.tsx @@ -0,0 +1,5 @@ +import PurchaseFlow from "@/components/dashboard/purchase/PurchaseFlow"; + +export default function AirtimePage() { + return ; +} \ No newline at end of file diff --git a/app/dashboard/service/electricity/page.tsx b/app/dashboard/service/electricity/page.tsx new file mode 100644 index 0000000..777f63a --- /dev/null +++ b/app/dashboard/service/electricity/page.tsx @@ -0,0 +1,5 @@ +import PurchaseFlow from "@/components/dashboard/purchase/PurchaseFlow"; + +export default function AirtimePage() { + return ; +} diff --git a/app/dashboard/service/layout.tsx b/app/dashboard/service/layout.tsx new file mode 100644 index 0000000..a5fb4ba --- /dev/null +++ b/app/dashboard/service/layout.tsx @@ -0,0 +1,9 @@ +import React, { ReactNode } from 'react' + +export default function ServiceLayout({children}: Readonly<{children: ReactNode}>) { + return ( +
+ {children} +
+ ) +} diff --git a/app/dashboard/service/page.tsx b/app/dashboard/service/page.tsx new file mode 100644 index 0000000..296b8ea --- /dev/null +++ b/app/dashboard/service/page.tsx @@ -0,0 +1,10 @@ +"use client" + +import { useRouter } from 'next/navigation' + +export default function ServiceRoute() { + const router = useRouter() + return ( + router.push("/dashboard/service/airtime") + ) +} diff --git a/components/dashboard/tabs/payment-split.tsx b/app/dashboard/split/page.tsx similarity index 100% rename from components/dashboard/tabs/payment-split.tsx rename to app/dashboard/split/page.tsx diff --git a/components/dashboard/tabs/swap.tsx b/app/dashboard/swap/page.tsx similarity index 100% rename from components/dashboard/tabs/swap.tsx rename to app/dashboard/swap/page.tsx diff --git a/app/dashboard/test/page.tsx b/app/dashboard/test/page.tsx new file mode 100644 index 0000000..1b45fc4 --- /dev/null +++ b/app/dashboard/test/page.tsx @@ -0,0 +1,10 @@ +import WalletSAndBalance from '@/components/modals/walletSAndBalance' +import React from 'react' + +export default function TEst() { + return ( +
+ {/* */} +
+ ) +} diff --git a/components/dashboard/tabs/top-up.tsx b/app/dashboard/topup/page.tsx similarity index 100% rename from components/dashboard/tabs/top-up.tsx rename to app/dashboard/topup/page.tsx diff --git a/app/globals.css b/app/globals.css index f9f2812..03ef8cd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -111,6 +111,8 @@ /* Ethereum purple */ } + + .dark { /* Dark mode colors - Professional dark theme with VELO brand colors */ --background: #0f172a; diff --git a/components/dashboard/mobile-bottom-nav.tsx b/components/dashboard/mobile-bottom-nav.tsx index d1b6f9c..2a5657e 100644 --- a/components/dashboard/mobile-bottom-nav.tsx +++ b/components/dashboard/mobile-bottom-nav.tsx @@ -1,49 +1,32 @@ -"use client" +"use client"; -import { Button } from "@/components/ui/buttons" -import { QrCode, Send, ArrowDownToLine, History, Home } from "lucide-react" - - -interface MobileBottomNavProps { - activeTab: string - setActiveTab: React.Dispatch> -} +import { QrCode, Send, ArrowDownToLine, History, Home, Bell, User, HistoryIcon } from "lucide-react"; +import Link from "next/link"; const navItems = [ - { icon: Home, label: "Dashboard", active: true }, - { icon: QrCode, label: "QRPayment", active: false }, - { icon: Send, label: "Send", active: false }, - { icon: ArrowDownToLine, label: "Receive funds", active: false }, - { icon: History, label: "History", active: false }, -] - -export function MobileBottomNav({ activeTab, setActiveTab }: MobileBottomNavProps) { - + { icon: Home, label: "Dashboard", link: "/dashboard" }, + { icon: Bell, label: "Notification", link: "/dashboard/notification" }, + { icon: HistoryIcon, label: "History", link: "/dashboard/profile" }, + { icon: User, label: "Profile", link: "/dashboard/profile" }, +]; +export function MobileBottomNav() { return (
-
+
{navItems.map((item, index) => ( - + ))}
- ) + ); } diff --git a/components/dashboard/purchase/PurchaseCommon/AmountGrid.tsx b/components/dashboard/purchase/PurchaseCommon/AmountGrid.tsx new file mode 100644 index 0000000..97f8dde --- /dev/null +++ b/components/dashboard/purchase/PurchaseCommon/AmountGrid.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { motion } from "framer-motion"; +import { AlertCircle } from "lucide-react"; + +interface AmountGridProps { + value: string; + onChange: (amount: string) => void; + presetAmounts: number[]; + minAmount?: number; + maxAmount?: number; + className?: string; +} + +export function AmountGrid({ + value, + onChange, + presetAmounts, + minAmount, + maxAmount, + className = "", +}: AmountGridProps) { + const numericValue = parseFloat(value) || 0; + + const isBelowMin = + minAmount !== undefined && numericValue > 0 && numericValue < minAmount; + const isAboveMax = maxAmount !== undefined && numericValue > maxAmount; + + const filteredPresets = presetAmounts.filter((amount) => { + if (minAmount !== undefined && amount < minAmount) return false; + if (maxAmount !== undefined && amount > maxAmount) return false; + return true; + }); + + const handleCustomAmountChange = (e: React.ChangeEvent) => { + const val = e.target.value; + if (val === "" || /^\d*$/.test(val)) { + onChange(val); + } + }; + + return ( +
+
+ + {(minAmount || maxAmount) && ( + + {minAmount && `Min: ₦${minAmount.toLocaleString()}`} + {minAmount && maxAmount && " • "} + {maxAmount && `Max: ₦${maxAmount.toLocaleString()}`} + + )} +
+ + {filteredPresets.length > 0 && ( +
+ {filteredPresets.map((amount) => ( + onChange(amount.toString())} + className={`p-3 rounded-xl transition-all border ${ + value === amount.toString() + ? " bg-primary/10 text-primary font-semibold" + : " hover:border-primary/50 hover:bg-accent" + }`} + > + ₦{amount.toLocaleString()} + + ))} +
+ )} + +
+ + {(isBelowMin || isAboveMax) && ( +
+ +
+ )} +
+ + {(isBelowMin || isAboveMax) && ( +

+ {isBelowMin + ? `Amount must be at least ₦${minAmount?.toLocaleString()}` + : `Amount cannot exceed ₦${maxAmount?.toLocaleString()}`} +

+ )} +
+ ); +} \ No newline at end of file diff --git a/components/dashboard/purchase/PurchaseCommon/CustomerInput.tsx b/components/dashboard/purchase/PurchaseCommon/CustomerInput.tsx new file mode 100644 index 0000000..f02bf49 --- /dev/null +++ b/components/dashboard/purchase/PurchaseCommon/CustomerInput.tsx @@ -0,0 +1,73 @@ +import React from "react"; + +interface CustomerInputProps { + type: "airtime" | "data" | "electricity"; + value: string; + onChange: (value: string) => void; + config: { + customerLabel: string; + placeholder: string; + }; + className?: string; +} + +export function CustomerInput({ + type, + value, + onChange, + config, + className = "", +}: CustomerInputProps) { + const handleChange = (e: React.ChangeEvent) => { + let inputValue = e.target.value; + + // Phone number validation (numbers only) + if (type !== "electricity") { + inputValue = inputValue.replace(/[^\d]/g, ''); + // Limit to 10 digits for Nigerian phone numbers + if (inputValue.length > 10) { + inputValue = inputValue.substring(0, 10); + } + } + + onChange(inputValue); + }; + + const getInputType = () => { + if (type === "electricity") return "text"; + return "tel"; + }; + + return ( +
+ + +
+ {type !== "electricity" && ( +
+ +234 +
+ )} + + +
+ + {type !== "electricity" && value && value.length < 10 && ( +

+ Enter a 10-digit Nigerian phone number +

+ )} +
+ ); +} \ No newline at end of file diff --git a/components/dashboard/purchase/PurchaseCommon/DataPlanSelect.tsx b/components/dashboard/purchase/PurchaseCommon/DataPlanSelect.tsx new file mode 100644 index 0000000..b421365 --- /dev/null +++ b/components/dashboard/purchase/PurchaseCommon/DataPlanSelect.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { motion } from "framer-motion"; + +interface DataPlan { + dataplanId: string; + name: string; + amount: string; + validity: string; + description?: string; +} + +interface DataPlanSelectProps { + dataPlans: DataPlan[]; + value: DataPlan | null; + onSelect: (plan: DataPlan) => void; + loading?: boolean; + className?: string; +} + +export function DataPlanSelect({ + dataPlans, + value, + onSelect, + loading = false, + className = "", +}: DataPlanSelectProps) { + if (loading) { + return ( +
+ +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+ ); + } + + if (dataPlans.length === 0) { + return ( +
+ +
+ No data plans available for this network +
+
+ ); + } + + const formatAmount = (amount: string) => { + const numAmount = parseInt(amount.replace(/[N₦,]/g, ""), 10); + return `₦${numAmount.toLocaleString()}`; + }; + + return ( +
+ +
+ {dataPlans.map((plan) => ( +
+ onSelect(plan)} + className={`w-full p-4 rounded-xl text-left transition-all ${ + value?.dataplanId === plan.dataplanId + ? "bg-primary/10" + : "bg-card hover:bg-accent border border-border" + }`} + > +
+
+

{plan.name}

+ +
+ + {formatAmount(plan.amount)} + +
+
+
+
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/components/dashboard/purchase/PurchaseCommon/MeterTypeSelect.tsx b/components/dashboard/purchase/PurchaseCommon/MeterTypeSelect.tsx new file mode 100644 index 0000000..19256bb --- /dev/null +++ b/components/dashboard/purchase/PurchaseCommon/MeterTypeSelect.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { motion } from "framer-motion"; + +interface MeterType { + value: "prepaid" | "postpaid"; + label: string; +} + +interface MeterTypeSelectProps { + meterTypes: MeterType[]; + value: "prepaid" | "postpaid"; + onChange: (type: "prepaid" | "postpaid") => void; + className?: string; +} + +export function MeterTypeSelect({ + meterTypes, + value, + onChange, + className = "", +}: MeterTypeSelectProps) { + if (meterTypes.length === 0) { + return null; + } + + return ( +
+ +
+ {meterTypes.map((type) => ( + onChange(type.value)} + className={`p-4 rounded-xl transition-all border ${ + value === type.value + ? "border-primary bg-primary/10 text-primary font-semibold" + : "border-border hover:border-primary/50 hover:bg-accent" + }`} + > +
+
{type.label}
+
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/components/dashboard/purchase/PurchaseCommon/ProviderSelect.tsx b/components/dashboard/purchase/PurchaseCommon/ProviderSelect.tsx new file mode 100644 index 0000000..d1aadb3 --- /dev/null +++ b/components/dashboard/purchase/PurchaseCommon/ProviderSelect.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import Image from "next/image"; +import { motion } from "framer-motion"; + +interface Provider { + serviceID: string; + name: string; + image: string; + code?: string; + minAmount?: number; + maxAmount?: number; +} + +interface ProviderSelectProps { + providers: Provider[]; + value: string; + onChange: (service_id: string) => void; + loading?: boolean; + className?: string; +} + +export function ProviderSelect({ + providers, + value, + onChange, + loading = false, + className = "", +}: ProviderSelectProps) { + if (loading) { + return ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+ ); + } + + if (providers.length === 0) { + return ( +
+ No providers available +
+ ); + } + + return ( +
+ {providers.map((provider) => ( + onChange(provider.serviceID)} + className={`w-full rounded-2xl transition-all ${ + value === provider.serviceID + ? " border border-border bg-primary/10 text-primary" + : " hover:border hover:border-primary/50 bg-muted/30" + }`} + style={{ minWidth: "100px" }} + > +
+
+ {provider.name} { + e.currentTarget.style.display = "none"; + const parent = e.currentTarget.parentElement; + if (parent) { + parent.innerHTML = ` +
+ ${provider.name.substring(0, 2)} +
+ `; + } + }} + /> +
+ + {provider.name} + +
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/components/dashboard/purchase/PurchaseCommon/PurchasePinDialog.tsx b/components/dashboard/purchase/PurchaseCommon/PurchasePinDialog.tsx new file mode 100644 index 0000000..0d43d8b --- /dev/null +++ b/components/dashboard/purchase/PurchaseCommon/PurchasePinDialog.tsx @@ -0,0 +1,84 @@ +import React, { useState, useRef } from "react"; +import { motion } from "framer-motion"; +import { Loader2 } from "lucide-react"; + +interface PurchasePinDialogProps { + isOpen: boolean; + onClose: () => void; + onPinComplete: (pin: string) => void; + isLoading: boolean; +} + +export function PurchasePinDialog({ + isOpen, + onClose, + onPinComplete, + isLoading, +}: PurchasePinDialogProps) { + const [pin, setPin] = useState(["", "", "", ""]); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + const handleChange = (index: number, value: string) => { + if (value.length > 1) return; + if (value && !/^\d$/.test(value)) return; + + const newPin = [...pin]; + newPin[index] = value; + setPin(newPin); + + if (value && index < 3) { + inputRefs.current[index + 1]?.focus(); + } + + if (newPin.every((digit) => digit !== "") && index === 3) { + onPinComplete(newPin.join("")); + } + }; + + if (!isOpen) return null; + + return ( + + e.stopPropagation()} + className="bg-card border rounded-2xl p-8 max-w-md w-full mx-4" + > +

Enter PIN

+ +
+ {pin.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="password" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleChange(index, e.target.value)} + disabled={isLoading} + className="w-16 h-16 text-center bg-background border-2 rounded-xl text-2xl focus:outline-none focus:border-primary" + /> + ))} +
+ + {isLoading && ( +
+ + Processing... +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/components/dashboard/purchase/PurchaseCommon/SuccessScreen.tsx b/components/dashboard/purchase/PurchaseCommon/SuccessScreen.tsx new file mode 100644 index 0000000..9164288 --- /dev/null +++ b/components/dashboard/purchase/PurchaseCommon/SuccessScreen.tsx @@ -0,0 +1,238 @@ +import React from "react"; +import { motion } from "framer-motion"; +import { Check, X, Home, Download, Copy } from "lucide-react"; +import { PurchaseConfig } from "@/components/hooks/usePurchaseConfig"; + +interface SuccessScreenProps { + success: boolean | null; + type: "airtime" | "data" | "electricity"; + formData: any; + providers: any[]; + transactionData: any; + config: PurchaseConfig; + formatCustomerId: (id: string) => string; + onReset: () => void; + className?: string; +} + +export function SuccessScreen({ + success, + type, + formData, + providers, + transactionData, + config, + formatCustomerId, + onReset, + className = "", +}: SuccessScreenProps) { + const handleCopy = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + // You could add a toast notification here + } catch (err) { + console.error("Failed to copy:", err); + } + }; + + const handleDownload = () => { + // Implement receipt download logic + console.log("Download receipt"); + }; + + if (success === null) { + return ( +
+
Processing transaction...
+
+ ); + } + + return ( + + {/* Animated Icon */} + + {success ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ + {/* Title */} +
+

+ {success ? "Transaction Successful!" : "Transaction Failed"} +

+

+ {success + ? "Your transaction has been processed successfully" + : "Something went wrong. Please try again."} +

+
+ + {/* Transaction Details */} + {success && ( + +

Transaction Details

+ +
+
+ Amount + + ₦{parseInt(formData.amount || "0").toLocaleString()} + +
+ +
+ Provider + + { + providers.find((p) => p.serviceID === formData.service_id) + ?.name + } + +
+ + {formData.customer_id && ( +
+ + {type === "electricity" ? "Meter Number" : "Phone Number"} + + + {type === "electricity" + ? formData.customer_id + : `234${formData.customer_id}`} + +
+ )} + + {transactionData?.purchaseId && ( +
+ Transaction ID +
+ + {transactionData.purchaseId.slice(0, 8)}... + + +
+
+ )} + + {transactionData?.meterToken && ( +
+
+
+ Meter Token +

+ Use this token to recharge your meter +

+
+
+ + {transactionData.meterToken.slice(0, 12)}... + + +
+
+
+ )} +
+ + {/* Download Receipt Button */} +
+ +
+
+ )} + + {/* Action Buttons */} +
+ + + {success ? "New Purchase" : "Try Again"} + + + {success && ( + + )} +
+ + {/* Confetti Effect (Success only) */} + {success && ( +
+ {[...Array(20)].map((_, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/components/dashboard/purchase/PurchaseFlow.tsx b/components/dashboard/purchase/PurchaseFlow.tsx new file mode 100644 index 0000000..be29407 --- /dev/null +++ b/components/dashboard/purchase/PurchaseFlow.tsx @@ -0,0 +1,212 @@ +// /components/purchase/PurchaseFlow.tsx +"use client"; + +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Loader2, AlertCircle } from "lucide-react"; + +// Import hooks and sub-components +import { usePurchaseFlow } from "@/components/hooks/usePurchaseFlow"; +import { usePurchaseConfig } from "@/components/hooks/usePurchaseConfig"; +import { ProviderSelection } from "./PurchaseSteps/ProviderSelection"; +import { PaymentSelection } from "./PurchaseSteps/PaymentSelection"; +import { ReviewConfirmation } from "./PurchaseSteps/ReviewConfirmation"; +import { SuccessScreen } from "./PurchaseCommon/SuccessScreen"; +import { PurchasePinDialog } from "./PurchaseCommon/PurchasePinDialog"; + +interface PurchaseProps { + type: "airtime" | "data" | "electricity"; +} + +export default function PurchaseFlow({ type }: PurchaseProps) { + const { + // State + step, + loading, + success, + showPinDialog, + isSending, + selectedToken, + toAddress, + errorMessage, + verifyingMeter, + meterVerified, + meterVerificationMessage, + formData, + providers, + dataPlans, + electricityCompanies, + meterTypes, + transactionData, + + // Handlers + setStep, + handleBack, + handleNext, + handleConfirm, + handleTokenSelect, + setFormData, + handleSendWithPin, + setShowPinDialog, + handleVerifyMeter, + resetForm, + + // Validation + isStep1Valid, + validationError, + + // Utilities + requiredCryptoAmount, + currentWalletBalance, + } = usePurchaseFlow({ type }); + + // Use the config hook + const { + config, + getPresetAmounts, + getValidationRules, + getRequiredFields, + getRecommendedTokens, + formatCustomerId, + getTransactionDescription, + getProgressPercentage, + } = usePurchaseConfig(type); + + const presetAmounts = getPresetAmounts; + + const renderStep = () => { + switch (step) { + case 1: + return ( + + ); + + case 2: + return ( + + ); + + case 3: + return ( + + ); + + case 4: + return ( + + ); + + default: + return null; + } + }; + + if (loading && step === 1) { + return ( +
+ +

Loading purchase options...

+
+ ); + } + + return ( +
+ {/* Progress Bar */} +
+
+

{config.title}

+ + Step {step} of 4 + +
+
+
+
+
+ + {/* PIN Dialog */} + setShowPinDialog(false)} + onPinComplete={handleSendWithPin} + isLoading={isSending} + /> + + + + {renderStep()} + + + + {/* Global Error Message */} + {errorMessage && ( + +
+ + {errorMessage} +
+
+ )} +
+ ); +} diff --git a/components/dashboard/purchase/PurchaseSteps/PaymentSelection.tsx b/components/dashboard/purchase/PurchaseSteps/PaymentSelection.tsx new file mode 100644 index 0000000..d38b4ef --- /dev/null +++ b/components/dashboard/purchase/PurchaseSteps/PaymentSelection.tsx @@ -0,0 +1,81 @@ +import { motion } from "framer-motion"; +import { ArrowLeft, ChevronRight } from "lucide-react"; +import WalletSAndBalance from "@/components/modals/walletSAndBalance"; + +interface PaymentSelectionProps { + type: string; + formData: any; + selectedToken: string; + providers: any[]; + config: any; + onTokenSelect: (token: string) => void; + onBack: () => void; + onNext: () => void; +} + +export function PaymentSelection({ + formData, + selectedToken, + providers, + onTokenSelect, + onBack, + onNext, +}: PaymentSelectionProps) { + // const [selectedToken, setSelectedToken] = useState(null); + + + return ( + +
+ +

Select Payment Method

+
+ +
+
+
+

Amount

+

+ ₦{parseInt(formData.amount || "0").toLocaleString()} +

+
+
+

Provider

+

+ {providers.find(p => p.serviceID === formData.service_id)?.name} +

+
+
+
+ +
+ +
+ +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/components/dashboard/purchase/PurchaseSteps/ProviderSelection.tsx b/components/dashboard/purchase/PurchaseSteps/ProviderSelection.tsx new file mode 100644 index 0000000..085bbff --- /dev/null +++ b/components/dashboard/purchase/PurchaseSteps/ProviderSelection.tsx @@ -0,0 +1,201 @@ +// /components/purchase/PurchaseSteps/ProviderSelection.tsx +import { motion } from "framer-motion"; +import { ChevronRight, AlertCircle, Loader2 } from "lucide-react"; +import { ProviderSelect } from "../PurchaseCommon/ProviderSelect"; +import { AmountGrid } from "../PurchaseCommon/AmountGrid"; +import { CustomerInput } from "../PurchaseCommon/CustomerInput"; +import { DataPlanSelect } from "../PurchaseCommon/DataPlanSelect"; +import { MeterTypeSelect } from "../PurchaseCommon/MeterTypeSelect"; + +interface ProviderSelectionProps { + type: "airtime" | "data" | "electricity"; + formData: any; + setFormData: (data: any) => void; + providers: any[]; + dataPlans: any[]; + meterTypes: any[]; + presetAmounts: number[]; + verifyingMeter: boolean; + meterVerified: boolean; + meterVerificationMessage: string; + config: any; + isStep1Valid: () => boolean; + errorMessage?: string; + loading?: boolean; + onNext: () => void; + onVerifyMeter: () => void; +} + +export function ProviderSelection({ + type, + formData, + setFormData, + providers, + dataPlans, + meterTypes, + presetAmounts, + verifyingMeter, + meterVerified, + meterVerificationMessage, + config, + isStep1Valid, + errorMessage, + loading = false, + onNext, + onVerifyMeter, +}: ProviderSelectionProps) { + const updateFormData = (field: string, value: any) => { + setFormData((prev: any) => ({ ...prev, [field]: value })); + }; + + // Get provider min/max amounts if available + const selectedProvider = providers.find(p => p.serviceID === formData.service_id); + const minAmount = selectedProvider?.minAmount; + const maxAmount = selectedProvider?.maxAmount; + + if (loading && providers.length === 0) { + return ( +
+ +

Loading providers...

+
+ ); + } + + return ( + + {/* Provider Selection */} +
+ + updateFormData("service_id", serviceId)} + loading={loading} + /> +
+ + {/* Amount Grid (for airtime/electricity) */} + {config.showAmountGrid && formData.service_id && ( + updateFormData("amount", amount)} + presetAmounts={presetAmounts} + minAmount={minAmount} + maxAmount={maxAmount} + /> + )} + + {/* Data Plan Selection (for data purchases) */} + {type === "data" && formData.service_id && ( + updateFormData("dataplan", plan)} + loading={loading} + /> + )} + + {/* Customer Input (Phone/Meter Number) */} + {formData.service_id && (config.showAmountGrid || type === "data") && ( + updateFormData("customer_id", value)} + config={config} + /> + )} + + {/* Meter Type Selection (for electricity) */} + {type === "electricity" && config.showMeterType && ( + updateFormData("meterType", type)} + /> + )} + + {/* Meter Verification (for electricity) */} + {config.showVerifyButton && formData.service_id && formData.customer_id && ( +
+ + + {meterVerificationMessage && ( +
+ {meterVerificationMessage} +
+ )} +
+ )} + + {/* Phone Number for Electricity (after meter verification) */} + {type === "electricity" && formData.customer_id && meterVerified && ( +
+ + updateFormData("phoneNo", e.target.value.replace(/[^\d]/g, ''))} + placeholder="08123456789" + className="w-full p-4 rounded-lg border border-border bg-background placeholder:text-muted-foreground focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + maxLength={11} + /> +

+ Enter your phone number for notifications +

+
+ )} + + {/* Error Message */} + {errorMessage && ( +
+
+ + {errorMessage} +
+
+ )} + + {/* Continue Button */} + + Continue + + +
+ ); +} \ No newline at end of file diff --git a/components/dashboard/purchase/PurchaseSteps/ReviewConfirmation.tsx b/components/dashboard/purchase/PurchaseSteps/ReviewConfirmation.tsx new file mode 100644 index 0000000..dbfe821 --- /dev/null +++ b/components/dashboard/purchase/PurchaseSteps/ReviewConfirmation.tsx @@ -0,0 +1,135 @@ +// /components/purchase/PurchaseSteps/ReviewConfirmation.tsx +import { motion } from "framer-motion"; +import { ArrowLeft, ChevronRight, AlertCircle } from "lucide-react"; + +interface ReviewConfirmationProps { + type: string; + formData: any; + selectedToken: string; + providers: any[]; + config: any; + requiredCryptoAmount: number; + currentWalletBalance: number; + validationError: string | null; + onBack: () => void; + onConfirm: () => void; +} + +export function ReviewConfirmation({ + type, + formData, + selectedToken, + providers, + config, + requiredCryptoAmount, + currentWalletBalance, + validationError, + onBack, + onConfirm, +}: ReviewConfirmationProps) { + const hasInsufficientBalance = requiredCryptoAmount > currentWalletBalance; + + return ( + +
+ +

Transaction Summary

+
+ +
+
+ Product + {type} +
+
+ Provider + + {providers.find(p => p.serviceID === formData.service_id)?.name} + +
+
+ {config.customerLabel} + + {type !== "electricity" ? `234${formData.customer_id}` : formData.customer_id} + +
+
+ Payment Method + {selectedToken} +
+
+ Total Amount + + ₦{parseInt(formData.amount || "0").toLocaleString()} + +
+ + {/* Crypto Amount */} +
+
+ Required Crypto + + {requiredCryptoAmount.toFixed(6)} {selectedToken.toUpperCase()} + +
+
+ Your Balance + + {currentWalletBalance.toFixed(6)} {selectedToken.toUpperCase()} + +
+
+
+ + {hasInsufficientBalance && ( +
+
+ + Insufficient balance for this transaction +
+
+ )} + + {validationError && !hasInsufficientBalance && ( +
+
+ + {validationError} +
+
+ )} + +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/components/dashboard/quick-actions.tsx b/components/dashboard/quick-actions.tsx index aa9117a..be9df0a 100644 --- a/components/dashboard/quick-actions.tsx +++ b/components/dashboard/quick-actions.tsx @@ -1,77 +1,99 @@ -import { QrCode, Users, Send, ArrowDownToLine } from "lucide-react"; +import { + QrCode, + Users, + Send, + ArrowDownToLine, + Lightbulb, + Wifi, + Phone, + ArrowUpToLine, +} from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/cards"; import { Button } from "../ui/buttons"; import { Dispatch, SetStateAction } from "react"; +import Link from "next/link"; interface quickActionProps { setTab: Dispatch>; } const actions = [ + { + title: "Airtime", + link: "/dashboard/service/airtime", + icon: Phone, + gradient: "from-accent to-secondary", + }, + { + title: "Data", + link: "/dashboard/service/data", + icon: Wifi, + gradient: "from-char-2 to-accent", + }, + { + title: "Electricity", + link: "/dashboard/service/electricity", + icon: Lightbulb, + gradient: "from-primary to-success", + }, { title: "QRPayment", - description: "Scan or generate QR codes", + link: "/dashboard/merchant", icon: QrCode, gradient: "from-primary to-accent", }, { title: "Payment split", - description: "Split payments with others", + link: "/dashboard/split", icon: Users, gradient: "from-success to-chart-2", }, { title: "Send", - description: "Transfer to any wallet", + link: "/dashboard/send", icon: Send, gradient: "from-chart-3 to-chart-4", }, { title: "Receive funds", - description: "Get paid instantly", + link: "/dashboard/receive", icon: ArrowDownToLine, - gradient: "from-accent to-primary", + gradient: "from-primary to-chart-2", }, { title: "Top Up", - description: "Buy crypto with NGN", - icon: ArrowDownToLine, + link: "/dashboard/topup", + icon: ArrowUpToLine, gradient: "from-primary to-accent", }, ]; -export function QuickActions({ setTab }: quickActionProps) { +export function QuickActions() { return ( - +
Quick Actions - -
+ +
{actions.map((action) => { const Icon = action.icon; return ( - + ); })}
- +
); } diff --git a/components/dashboard/recent-activity.tsx b/components/dashboard/recent-activity.tsx index 43e3cb4..f213e44 100644 --- a/components/dashboard/recent-activity.tsx +++ b/components/dashboard/recent-activity.tsx @@ -4,20 +4,20 @@ import { CardHeader, CardTitle, } from "@/components/ui/cards"; -import { Button } from "@/components/ui/buttons"; import { ArrowDownLeft, ArrowUpRight, ChevronRight } from "lucide-react"; -import { DashboardProps } from "./tabs/dashboard"; import { useNotifications } from "../hooks/useNotifications"; import { shortenAddress } from "../lib/utils"; +import Link from "next/link"; -export function RecentActivity({ activeTab }: DashboardProps) { +export function RecentActivity() { const { notifications } = useNotifications(); const filtered = notifications.filter((notif) => { - return notif.title === "Deposit Successful" || notif.title === "Tokens Sent"; + return ( + notif.title === "Deposit Successful" || notif.title === "Tokens Sent" + ); }); - const finalNotificationFix = filtered.slice(0, 5); return ( @@ -26,15 +26,13 @@ export function RecentActivity({ activeTab }: DashboardProps) { Recent Activity - + {finalNotificationFix.map((notification) => ( @@ -62,10 +60,10 @@ export function RecentActivity({ activeTab }: DashboardProps) {
{notification.time}
-
+
{notification.title === "Deposit Successful" && (
- {notification.details.amount} {notification.details.chain} + {notification.details.amount} {notification.details.chain}
)} @@ -79,7 +77,7 @@ export function RecentActivity({ activeTab }: DashboardProps) {
{shortenAddress(notification.details.address, 6)}
)} - {notification.title === "Tokens Sent" && ( + {notification.title === "Tokens Sent" && (
{shortenAddress(notification.details.txHash, 6)}
)}
diff --git a/components/dashboard/service-flow.tsx b/components/dashboard/service-flow.tsx deleted file mode 100644 index 7068d93..0000000 --- a/components/dashboard/service-flow.tsx +++ /dev/null @@ -1,1697 +0,0 @@ -"use client"; -import { motion, AnimatePresence } from "framer-motion"; -import { - Check, - X, - PhoneCall, - Wifi, - Zap, - ArrowRight, - Loader2, - AlertCircle, -} from "lucide-react"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { TransactionPinDialog } from "../ui/transaction-pin-dialog"; -import { normalizeStarknetAddress } from "../lib/utils"; -import { useAuth } from "../context/AuthContext"; -import { useWalletData } from "../hooks"; -import useExchangeRates from "../hooks/useExchangeRate"; -import { AddressDropdown } from "../modals/addressDropDown"; -import Image from "next/image"; -import { - apiClient, - type DataPlan, - type ElectricityCompany, - type MeterType, - type ExpectedAmount, - type SupportedNetwork, -} from "@/lib/api-client"; -import { validatePhoneNumber } from "@/lib/utils"; - -export enum Blockchain { - ETHEREUM = "ethereum", - BITCOIN = "bitcoin", - SOLANA = "solana", - STELLAR = "stellar", - POLKADOT = "polkadot", - STARKNET = "starknet", - USDT_ERC20 = "usdt-erc20", -} - -type PurchaseType = "airtime" | "data" | "electricity"; - -interface PurchaseProps { - type: PurchaseType; -} - -type TransactionData = { - dateTime: string; - paymentMethod: string; - status: string; - description: string; - transactionId: string; - providerLogo: string; - providerName: string; - planName: string; - meterToken?: string; -}; - -type Provider = { - serviceID: string; - name: string; - image: string; - code?: string; - minAmount?: number; - maxAmount?: number; -}; - -export default function Purchase({ type }: PurchaseProps) { - const [step, setStep] = useState(1); - const [loading, setLoading] = useState(false); - const [success, setSuccess] = useState(null); - const [showPinDialog, setShowPinDialog] = useState(false); - const [isSending, setIsSending] = useState(false); - const [selectedToken, setSelectedToken] = useState("ethereum"); - const [toAddress, setToAddress] = useState(""); - const [txHash, setTxHash] = useState(""); - const [errorMessage, setErrorMessage] = useState(""); - const [merchantFallback, setMerchantFallback] = useState(false); - const [verifyingMeter, setVerifyingMeter] = useState(false); - const [meterVerified, setMeterVerified] = useState(false); - const [meterVerificationMessage, setMeterVerificationMessage] = useState(""); - - const [formData, setFormData] = useState({ - service_id: "", - amount: "", - customer_id: "", - meterType: "prepaid" as "prepaid" | "postpaid", - dataplan: null as DataPlan | null, - expectedAmount: null as ExpectedAmount | null, - transactionData: null as TransactionData | null, - phoneNo: "", - }); - - const { sendTransaction } = useAuth(); - const { rates } = useExchangeRates(); - const { addresses, balances } = useWalletData(); - - const [providers, setProviders] = useState([]); - const [dataPlans, setDataPlans] = useState([]); - const [electricityCompanies, setElectricityCompanies] = useState< - ElectricityCompany[] - >([]); - const [meterTypes, setMeterTypes] = useState([]); - - const presetAmounts = [100, 200, 500, 1000, 2000, 5000]; - - const getConfig = () => { - const config = { - airtime: { - title: "Buy Airtime", - icon: PhoneCall, - step1Title: "Select network", - step1Description: "Choose your network provider", - customerLabel: "Phone Number", - placeholder: "8012345678", - showAmountGrid: true, - showVariations: false, - showMeterType: false, - showVerifyButton: false, - }, - data: { - title: "Purchase Data", - icon: Wifi, - step1Title: "Select network", - step1Description: "Choose your network provider", - customerLabel: "Phone Number", - placeholder: "8012345678", - showAmountGrid: false, - showVariations: true, - showMeterType: false, - showVerifyButton: false, - }, - electricity: { - title: "Electricity Bill", - icon: Zap, - step1Title: "Select provider", - step1Description: "Choose your electricity provider", - customerLabel: "Meter Number", - placeholder: "Enter meter number", - showAmountGrid: true, - showVariations: false, - showMeterType: true, - showVerifyButton: true, - }, - }; - return config[type]; - }; - - const getToAddress = (chain: string) => { - // Allow a runtime override for quick testing in the browser. Set - // `window.__VELO_MERCHANT_WALLETS = { ethereum: '0x...', solana: '...'} - // in DevTools to test without restarting the dev server. - if (typeof window !== "undefined") { - const runtime: any = (window as any).__VELO_MERCHANT_WALLETS; - if (runtime && typeof runtime === "object" && runtime[chain]) { - return String(runtime[chain]); - } - } - - const walletMap: { [key: string]: string | undefined } = { - ethereum: process.env.NEXT_PUBLIC_ETH_WALLET, - bitcoin: process.env.NEXT_PUBLIC_BTC_WALLET, - solana: process.env.NEXT_PUBLIC_SOL_WALLET, - stellar: process.env.NEXT_PUBLIC_XLM_WALLET, - polkadot: process.env.NEXT_PUBLIC_DOT_WALLET, - starknet: process.env.NEXT_PUBLIC_STRK_WALLET, - "usdt-erc20": process.env.NEXT_PUBLIC_USDT_WALLET, - }; - return walletMap[chain] || ""; - }; - - const config = getConfig(); - - const resetForm = useCallback(() => { - setToAddress(""); - setTxHash(""); - setErrorMessage(""); - setMeterVerified(false); - setMeterVerificationMessage(""); - }, []); - - const handleTokenSelect = useCallback((chain: string) => { - setSelectedToken(chain); - setToAddress(getToAddress(chain.toLowerCase())); - // resetForm(); - }, []); - - // Fetch providers based on type - const fetchProviders = async () => { - setLoading(true); - try { - if (type === "electricity") { - const { companies } = await apiClient.getElectricitySupportedOptions(); - setElectricityCompanies(companies); - - const mappedProviders: Provider[] = companies.map((company) => ({ - serviceID: company.value, - name: company.label, - image: `/img/${company.value}.png`, - code: company.code, - minAmount: company.minAmount, - maxAmount: company.maxAmount, - })); - setProviders(mappedProviders); - - // console.log("providers xxxxxxxxx ", mappedProviders); - } else { - // For airtime and data - const networks = - type === "data" - ? await apiClient.getDataSupportedNetworks() - : await apiClient.getAirtimeSupportedNetworks(); - - const mappedProviders: Provider[] = networks.map((network) => ({ - serviceID: network.value, - name: network.label, - image: `/img/${network.value.toLowerCase()}.png`, // Ensure lowercase - })); - setProviders(mappedProviders); - - // console.log("Airtime/Data providers loaded:", mappedProviders); - } - } catch (error) { - console.error("Failed to fetch providers:", error); - setErrorMessage("Failed to load providers. Please try again."); - } finally { - setLoading(false); - } - }; - - // Fetch data plans for selected network - const fetchDataPlans = async (network: string) => { - setLoading(true); - try { - const plans = await apiClient.getDataPlans(network, false); - setDataPlans(plans); - } catch (error) { - console.error("Failed to fetch data plans:", error); - setErrorMessage("Failed to load data plans. Please try again."); - } finally { - setLoading(false); - } - }; - - // Fetch meter types for electricity - const fetchMeterTypes = async () => { - setLoading(true); - try { - const { meterTypes: types } = - await apiClient.getElectricitySupportedOptions(); - setMeterTypes(types); - } catch (error) { - console.error("Failed to fetch meter types:", error); - } finally { - setLoading(false); - } - }; - - // Verify meter number - const handleVerifyMeter = async () => { - if (!formData.customer_id || !formData.service_id) { - setMeterVerificationMessage( - "Please enter meter number and select provider" - ); - return; - } - - setVerifyingMeter(true); - setMeterVerificationMessage(""); - - try { - const result = await apiClient.verifyElectricityMeter( - formData.service_id, - formData.customer_id - ); - - // console.log("results: ", result); - - // FIXED: Check the direct success property and data.valid - if (result.success && result.data && result.data.valid) { - setMeterVerified(true); - // console.log("meter", result.data); - - // Show customer name if available - const customerInfo = result.data.customerName - ? `✓ ${result.data.customerName} ` - : `✓ Meter verified: ${result.data.company}`; - - setMeterVerificationMessage(customerInfo); - } else { - setMeterVerified(false); - setMeterVerificationMessage(result.message || "✗ Invalid meter number"); - } - } catch (error: any) { - setMeterVerified(false); - setMeterVerificationMessage( - error.message || "Verification failed. Please try again." - ); - } finally { - setVerifyingMeter(false); - } - }; - - // Get expected crypto amount - const fetchExpectedAmount = async () => { - try { - let expectedAmount: ExpectedAmount; - - if (type === "airtime") { - const amount = parseFloat(formData.amount); - expectedAmount = await apiClient.getAirtimeExpectedAmount( - amount, - selectedToken - ); - } else if (type === "electricity") { - const amount = parseFloat(formData.amount); - expectedAmount = await apiClient.getElectricityExpectedAmount( - amount, - selectedToken - ); - } else if (type === "data" && formData.dataplan) { - expectedAmount = await apiClient.getDataExpectedAmount( - formData.dataplan.dataplanId, - formData.service_id, - selectedToken - ); - } else { - throw new Error("Invalid purchase configuration"); - } - - setFormData((prev) => ({ ...prev, expectedAmount })); - } catch (error: any) { - console.error("Failed to fetch expected amount:", error); - setErrorMessage(error.message || "Failed to calculate crypto amount"); - } - }; - - // console.log("calidate phone number", validatePhoneNumber("08101843464")); - - useEffect(() => { - fetchProviders(); - if (type === "electricity") { - fetchMeterTypes(); - } - }, [type]); - - useEffect(() => { - if (type === "data" && formData.service_id) { - fetchDataPlans(formData.service_id); - } - }, [type, formData.service_id]); - - useEffect(() => { - if (step === 2 && selectedToken) { - fetchExpectedAmount(); - } - }, [step, selectedToken, formData.amount, formData.dataplan]); - - const currentWalletBalance = useMemo(() => { - const balanceInfo = balances.find( - (b) => (b.chain || "").toLowerCase() === selectedToken.toLowerCase() - ); - return parseFloat(balanceInfo?.balance || "0"); - }, [balances, selectedToken]); - - const currentWalletAddress = useMemo(() => { - if (!addresses) return ""; - const addressInfo = addresses.find( - (addr) => (addr.chain || "").toLowerCase() === selectedToken.toLowerCase() - ); - return addressInfo?.address || ""; - }, [addresses, selectedToken]); - - // console.log("current wallet", currentWalletAddress); - const currentNetwork = useMemo(() => { - if (!addresses) return "mainnet"; - const addressInfo = addresses.find( - (addr) => (addr.chain || "").toLowerCase() === selectedToken.toLowerCase() - ); - return addressInfo?.network || "mainnet"; - }, [addresses, selectedToken]); - - const requiredCryptoAmount = useMemo(() => { - if (!formData.expectedAmount?.cryptoAmount) return 0; - - const amount = formData.expectedAmount.cryptoAmount; - // Always round UP to 7 decimal places - return Math.ceil(amount * 1e7) / 1e7; - }, [formData.expectedAmount]); - - // If backend expectedAmount isn't available, we can fall back to local - // exchange rates to estimate crypto needed: crypto = NGN amount / rate.ngn - const estimateCryptoFromRates = useCallback( - (ngnAmount: number, token: string) => { - try { - const r: any = (rates as any) || {}; - const rateFor = r[token]; - if (!rateFor || !rateFor.ngn) return 0; - const crypto = ngnAmount / parseFloat(String(rateFor.ngn)); - // Round up to 7 decimals like other logic - return Math.ceil(crypto * 1e7) / 1e7; - } catch (e) { - return 0; - } - }, - [rates] - ); - - // Find a token that has sufficient NGN-equivalent balance to cover the - // requested fiat amount. Prefers the currently selected token. - const findTokenWithSufficientBalance = useCallback( - (ngnAmount: number) => { - const rateMap: any = (rates as any) || {}; - // Build candidates from balances array - const candidates = (balances || []) - .map((b: any) => { - const token = b.chain; - const bal = parseFloat(b.balance || "0"); - const rate = rateMap[token]?.ngn ? parseFloat(rateMap[token].ngn) : 0; - return { token, bal, rate, ngnValue: bal * (rate || 0) }; - }) - .sort((a: any, b: any) => b.ngnValue - a.ngnValue); - - // Try current token first - const curr = candidates.find((c: any) => c.token === selectedToken); - if (curr && curr.ngnValue >= ngnAmount) return curr.token; - - // Otherwise pick first candidate with enough NGN value - const found = candidates.find((c: any) => c.ngnValue >= ngnAmount); - return found ? found.token : null; - }, - [balances, rates, selectedToken] - ); - - const validationError = useMemo(() => { - // Check that there's a configured merchant address for the selected token. - const merchantAddress = getToAddress(selectedToken.toLowerCase()) || ""; - - if (!currentWalletAddress) { - return "No wallet found for this currency. Add a wallet or select another currency."; - } - - if (!merchantAddress) { - // In production we require a configured merchant wallet. In development - // allow the flow to continue (auto-fallback to user's address) so - // developers can test payments locally without env vars. - if (process.env.NODE_ENV === "production") { - return `Merchant wallet for ${selectedToken.toUpperCase()} is not configured. Set NEXT_PUBLIC_${selectedToken.toUpperCase()}_WALLET or set window.__VELO_MERCHANT_WALLETS in DevTools.`; - } - // In dev, we don't block here — a dev-only fallback will be applied. - } - - if (!toAddress.trim()) { - return "Recipient address is required"; - } - - if (!formData.amount || parseFloat(formData.amount) <= 0) { - return "Amount must be greater than 0"; - } - - if (requiredCryptoAmount > currentWalletBalance) { - return "Insufficient balance"; - } - - if (type === "electricity" && config.showVerifyButton && !meterVerified) { - return "Please verify meter number first"; - } - - return null; - }, [ - currentWalletAddress, - selectedToken, - toAddress, - formData.amount, - requiredCryptoAmount, - currentWalletBalance, - type, - config.showVerifyButton, - meterVerified, - ]); - - useEffect(() => { - // Auto-fill recipient address when a token is selected or when wallet - // addresses change so the validation doesn't fail immediately on mount. - try { - const addr = getToAddress(selectedToken.toLowerCase()); - if (addr && !toAddress) { - setToAddress(addr); - } - } catch (e) { - // ignore - } - }, [selectedToken, addresses]); - - // Dev-only fallback: if there's no configured merchant address, auto-fill - // with the current user's wallet address so developers can test the flow. - useEffect(() => { - if (process.env.NODE_ENV === "production") return; - try { - const addr = getToAddress(selectedToken.toLowerCase()); - if (!addr && !toAddress && currentWalletAddress) { - setToAddress(currentWalletAddress); - setMerchantFallback(true); - } else { - setMerchantFallback(false); - } - } catch (e) { - // ignore - } - }, [selectedToken, addresses, currentWalletAddress, toAddress]); - - // When validation prevents proceeding to Confirm & Pay, surface a compact - // debug summary so developers can quickly see why without sifting through - // repeated console messages. This is intentionally a warning-level log. - useEffect(() => { - if (!validationError) return; - // Only surface developer warnings in non-production to reduce console noise - if (process.env.NODE_ENV !== "production") { - console.warn("Purchase validation blocked action:", { - validationError, - selectedToken, - toAddress, - requiredCryptoAmount, - currentWalletBalance, - currentWalletAddress, - }); - } - }, [validationError, selectedToken, toAddress, requiredCryptoAmount, currentWalletBalance, currentWalletAddress]); - - // When step 2 (payment) is entered and expectedAmount is available (or - // an NGN amount is entered), try to auto-select a token that has enough - // NGN-equivalent balance so the user doesn't hit "Insufficient balance". - useEffect(() => { - if (step !== 2) return; - const ngnAmount = parseFloat(formData.amount || "0"); - if (!ngnAmount || ngnAmount <= 0) return; - - // If backend provided expectedAmount, use that crypto amount and token - const expected = formData.expectedAmount; - if (expected && expected.cryptoAmount && expected.cryptoCurrency) { - // ensure selected token matches expected currency - const expectedToken = expected.chain || expected.cryptoCurrency?.toLowerCase(); - if (expectedToken && expectedToken !== selectedToken) { - setSelectedToken(expectedToken); - setToAddress(getToAddress(expectedToken)); - } - return; - } - - // Fallback: find a token with sufficient NGN equivalent value - const candidate = findTokenWithSufficientBalance(ngnAmount); - if (candidate && candidate !== selectedToken) { - setSelectedToken(candidate); - setToAddress(getToAddress(candidate)); - } - }, [step, formData.amount, formData.expectedAmount, findTokenWithSufficientBalance]); - - const handleSendWithPin = async (pin: string) => { - setErrorMessage(""); - - if (validationError) { - setErrorMessage(validationError); - setShowPinDialog(false); - return; - } - - setIsSending(true); - - try { - // Step 1: Send cryptocurrency transaction - let normalizedToAddress = toAddress.trim(); - - if (selectedToken === "starknet") { - try { - normalizedToAddress = normalizeStarknetAddress(toAddress, "starknet"); - } catch (error) { - throw new Error( - error instanceof Error - ? `Invalid Starknet address: ${error.message}` - : "Invalid Starknet address format" - ); - } - } - // If recipient equals the sender, handle safely depending on environment. - // - In development, simulate a transaction so developers can test the - // purchase flow without triggering backend validation (send-to-self). - // - In production, block the action to avoid accidental send-to-self. - if (normalizedToAddress === currentWalletAddress) { - if (process.env.NODE_ENV === "production") { - throw new Error("Recipient address cannot be the same as the sender."); - } - - // Dev behaviour: if we're already in a merchantFallback or the - // recipient equals the current wallet, simulate a tx so the rest of - // the purchase flow can be tested end-to-end without calling the - // backend /wallet/send with an invalid payload. - console.warn( - "Dev-mode: recipient equals sender — simulating transaction. Set a merchant wallet to test real sends." - ); - const fakeHash = `dev-tx-${Date.now()}`; - setTxHash(fakeHash); - setShowPinDialog(false); - await handleSubmitPurchase(fakeHash); - setStep(4); - return; - } - - const transactionResponse = await sendTransaction({ - chain: selectedToken, - network: currentNetwork, - toAddress: normalizedToAddress, - amount: requiredCryptoAmount.toString(), - fromAddress: currentWalletAddress, - transactionPin: pin, - }); - - setTxHash(transactionResponse.txHash); - setShowPinDialog(false); - - // Step 2: Submit purchase to backend with transaction hash - await handleSubmitPurchase(transactionResponse.txHash); - - setStep(4); - } catch (error: any) { - console.error("Transaction error:", error); - let errMsg = "Failed to send transaction. Please try again."; - - if (error.message) { - errMsg = error.message; - } else if (typeof error === "string") { - errMsg = error; - } - - setErrorMessage(errMsg); - setShowPinDialog(false); - } finally { - setIsSending(false); - } - }; - - const handleSubmitPurchase = async (transactionHash: string) => { - setLoading(true); - - try { - let response; - - if (type === "airtime") { - // Include metadata so backend has provider/expectedAmount/context - const provider = providers.find((p) => p.serviceID === formData.service_id) || null; - const metadata = { - provider, - expectedAmount: formData.expectedAmount || null, - selectedToken, - fromAddress: currentWalletAddress, - merchantAddress: getToAddress(selectedToken), - purchaseType: "AirtimePurchase", - }; - - response = await apiClient.purchaseAirtime({ - type: "airtime", - amount: parseFloat(formData.amount), - chain: selectedToken, - phoneNumber: validatePhoneNumber(formData.customer_id), - mobileNetwork: formData.service_id, - transactionHash, - metadata, - } as any); - } else if (type === "data" && formData.dataplan) { - const provider = providers.find((p) => p.serviceID === formData.service_id) || null; - const metadata = { - provider, - dataplan: formData.dataplan, - expectedAmount: formData.expectedAmount || null, - selectedToken, - fromAddress: currentWalletAddress, - merchantAddress: getToAddress(selectedToken), - purchaseType: "DataPurchase", - }; - - response = await apiClient.purchaseData({ - type: "data", - dataplanId: formData.dataplan.dataplanId, - amount: parseFloat(formData.dataplan.amount.replace(/[N₦,]/g, "")), - chain: selectedToken, - phoneNumber: validatePhoneNumber(formData.customer_id), - mobileNetwork: formData.service_id, - transactionHash, - metadata, - } as any); - } else if (type === "electricity") { - const companyInfo = electricityCompanies.find((c) => c.value === formData.service_id) || null; - const metadata = { - company: companyInfo, - expectedAmount: formData.expectedAmount || null, - selectedToken, - fromAddress: currentWalletAddress, - merchantAddress: getToAddress(selectedToken), - purchaseType: "ElectricityPurchase", - }; - - response = await apiClient.purchaseElectricity({ - type: "electricity", - amount: parseFloat(formData.amount), - chain: selectedToken, - company: formData.service_id, - meterType: formData.meterType, - meterNumber: formData.customer_id, - phoneNumber: validatePhoneNumber(formData.phoneNo), - transactionHash, - metadata, - } as any); - } else { - throw new Error("Invalid purchase type"); - } - - if (response.success) { - setSuccess(true); - - const transactionData: TransactionData = { - dateTime: new Date().toLocaleString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", - second: "2-digit", - }), - paymentMethod: "Cashley", - status: "Completed", - description: response.message, - transactionId: response.data.purchaseId, - providerLogo: - providers.find((p) => p.serviceID === formData.service_id)?.image || - "", - providerName: - providers.find((p) => p.serviceID === formData.service_id)?.name || - "", - planName: - response.data.planName || - formData.dataplan?.name || - `₦${formData.amount}`, - meterToken: response.data.meterToken, - }; - - setFormData((prev) => ({ ...prev, transactionData })); - } else { - setSuccess(false); - setErrorMessage(response.message || "Purchase failed"); - } - } catch (error: any) { - console.error("Purchase error:", error); - setSuccess(false); - setErrorMessage(error.message || "Failed to complete purchase"); - } finally { - setLoading(false); - } - }; - - const handleBack = () => { - if (step === 1) { - window.history.back(); - } else { - setStep((prev) => prev - 1); - setErrorMessage(""); - } - }; - - const handleNext = () => { - setErrorMessage(""); - setStep((prev) => prev + 1); - }; - - const handleConfirm = () => { - if (validationError) { - setErrorMessage(validationError); - return; - } - setShowPinDialog(true); - }; - - const data = { - serviceId: formData.service_id, - customerId: formData.customer_id, - }; - - // console.log("data", data); - - const isStep1Valid = () => { - if (!formData.service_id) return false; - if (!formData.customer_id) return false; - - if (type === "data" && !formData.dataplan) return false; - if ((type === "airtime" || type === "electricity") && !formData.amount) - return false; - if ( - type === "electricity" && - config.showVerifyButton && - !meterVerified && - !formData.phoneNo - ) - return false; - - return true; - }; - - const renderStep = () => { - switch (step) { - case 1: - return ( - -
-

{config.step1Title}

-

{config.step1Description}

-
- -
- {/* Provider Selection */} - { - setFormData((prev) => ({ - ...prev, - service_id, - dataplan: null, - amount: "", - })); - setMeterVerified(false); - setMeterVerificationMessage(""); - }} - loading={loading} - /> - - {/* Meter Type Selection (Electricity only) */} - {config.showMeterType && formData.service_id && ( - - setFormData((prev) => ({ ...prev, meterType })) - } - /> - )} - - {/* Data Plan Selection */} - {config.showVariations && formData.service_id && ( - - setFormData((prev) => ({ - ...prev, - dataplan, - amount: dataplan.amount.replace(/[N₦,]/g, ""), - })) - } - loading={loading} - /> - )} - - {/* Amount Grid (Airtime & Electricity) */} - {config.showAmountGrid && formData.service_id && ( - - setFormData((prev) => ({ ...prev, amount })) - } - presetAmounts={presetAmounts} - minAmount={ - providers.find((p) => p.serviceID === formData.service_id) - ?.minAmount - } - maxAmount={ - providers.find((p) => p.serviceID === formData.service_id) - ?.maxAmount - } - /> - )} - - {/* Customer ID Input */} - {formData.service_id && - (config.showAmountGrid || formData.dataplan) && ( -
- -
- {type !== "electricity" && ( -
234
- )} - { - setFormData((prev) => ({ - ...prev, - customer_id: e.target.value, - })); - setMeterVerified(false); - setMeterVerificationMessage(""); - }} - placeholder={config.placeholder} - type="tel" - className="flex-1 p-4 rounded-2xl border-none outline-none " - /> - {config.showVerifyButton && formData.customer_id && ( - - )} -
-
- - { - setFormData((prev) => ({ - ...prev, - phoneNo: e.target.value, - })); - setMeterVerified(false); - setMeterVerificationMessage(""); - }} - placeholder="08123456789" - type="tel" - className="flex-1 p-4 rounded-2xl border-none outline-none " - /> -
- {meterVerificationMessage && ( -
- {meterVerificationMessage} -
- )} -
- )} - - {errorMessage && ( -
- - {errorMessage} -
- )} - -
-
- ); - - case 2: - return ( - -
-

Select Payment Method

-

- Choose your preferred cryptocurrency -

-
- - - - {merchantFallback && ( -
- No merchant wallet configured for {selectedToken.toUpperCase()}. - Using your connected wallet address for testing only. Do not use - in production. To fix permanently, set the environment variable - NEXT_PUBLIC_{selectedToken.toUpperCase()}_WALLET - or run in the console: - {`window.__VELO_MERCHANT_WALLETS = ${JSON.stringify( - { [selectedToken]: currentWalletAddress } - )}`} -
- )} - - {formData.expectedAmount && ( -
-

Payment Details

-
-
- Amount (NGN): - - ₦{parseFloat(formData.amount).toLocaleString()} - -
-
- - Amount ({formData.expectedAmount.cryptoCurrency}): - - - {formData.expectedAmount.cryptoAmount.toFixed(8)} - -
- {formData.expectedAmount.planDetails && ( - <> -
- Plan: - - {formData.expectedAmount.planDetails.name} - -
- {formData.expectedAmount.planDetails.validity && ( -
- Validity: - - {formData.expectedAmount.planDetails.validity} - -
- )} - - )} -
-
- )} - - {errorMessage && ( -
- - {errorMessage} -
- )} - -
-
-
- ); - - case 3: - return ( - -
-
-

- Summary -

-

- Review your purchase -

-
- -
-
- - ₦{parseInt(formData.amount || "0").toLocaleString()} - - - - - - {formData.expectedAmount?.cryptoAmount.toFixed(8)}{" "} - {formData.expectedAmount?.cryptoCurrency} - -
- -
- - p.serviceID === formData.service_id) - ?.name || "" - } - /> - {type === "data" && formData.dataplan && ( - - )} - {type === "electricity" && ( - - )} - - -
-
- - {errorMessage && ( -
- - {errorMessage} -
- )} - -
-
-
- - setShowPinDialog(false)} - onPinComplete={handleSendWithPin} - isLoading={isSending} - title="Authorize Transaction" - description="Enter your transaction PIN to confirm this purchase" - /> -
- ); - - case 4: - return ( - -
- - {success ? ( - - ) : ( - - )} - - -
-

- {success ? "Transaction Successful" : "Transaction Failed"} -

-

- {success - ? "Your transaction has been processed successfully" - : "Something went wrong. Please try again."} -

- -
- Amount Sent - - ₦{parseInt(formData.amount || "0").toLocaleString()} - -
- -
-
- Beneficiary -
-
-
-
- {formData.transactionData?.providerLogo ? ( - provider - ) : ( -
- {providers.find((p) => p.serviceID === formData.service_id)?.name?.substring(0,3) || "P"} -
- )} -
-
-

- {type === "electricity" - ? formData.customer_id - : `234${formData.customer_id}`} -

-

- { - providers.find( - (p) => p.serviceID === formData.service_id - )?.name - } -

-
-
- -
-
-
- - {success && formData.transactionData && ( -
- - - - - - - {formData.transactionData.meterToken && ( -
-

- Meter Token -

-

- {formData.transactionData.meterToken} -

-
- )} -
- )} - - {!success && ( -
-

{errorMessage}

-
- )} -
- -
-
- -
-
-
- ); - - default: - return null; - } - }; - - return ( -
-
- {renderStep()} -
-
- ); -} - -// === MISSING SUB-COMPONENTS (Added) === - -function DataPlanSelect({ - dataPlans, - value, - onSelect, - loading, -}: { - dataPlans: DataPlan[]; - value: DataPlan | null; - onSelect: (plan: DataPlan) => void; - loading: boolean; -}) { - if (loading) { - return ( -
- -
- {[...Array(4)].map((_, i) => ( -
-
-
- ))} -
-
- ); - } - - return ( -
- -
- {dataPlans.map((plan) => ( -
- onSelect(plan)} - className="w-full p-4 rounded-2xl text-left transition-all " - > -
-
-

{plan.name}

-

{plan.validity}

-
- - ₦ - {parseInt(plan.amount.replace(/[N₦,]/g, "")).toLocaleString()} - -
-
-
- ))} -
-
- ); -} - -function MeterTypeSelect({ - meterTypes, - value, - onChange, -}: { - meterTypes: MeterType[]; - value: "prepaid" | "postpaid"; - onChange: (type: "prepaid" | "postpaid") => void; -}) { - return ( -
- -
- {meterTypes.map((type) => ( - onChange(type.value as "prepaid" | "postpaid")} - className={`p-4 rounded-2xl font-bold transition-all ${ - value === type.value - ? "ring-2 ring-blue-500 text-blue-700" - : " text-gray-700" - }`} - > - {type.label} - - ))} -
-
- ); -} - -// === REUSABLE TRANSACTION DETAIL COMPONENT === -function TransactionDetail({ - label, - value, - monospace = false, - link, - className = "", -}: { - label: string; - value: string; - monospace?: boolean; - link?: string; - className?: string; -}) { - return ( -
- {label} - {link ? ( - - {value.slice(0, 10)}...{value.slice(-8)} - - ) : ( - - {value} - - )} -
- ); -} - -// Sub-components -function ProviderSelect({ - providers, - value, - onChange, - loading, -}: { - providers: Provider[]; - value: string; - onChange: (service_id: string) => void; - loading: boolean; -}) { - if (loading) { - return ( -
-
- {[...Array(4)].map((_, i) => ( -
-
-
- ))} -
-
- ); - } - return ( -
-
- {providers.map((provider) => ( -
- onChange(provider.serviceID)} - className="flex flex-col items-center w-full rounded-2xl overflow-hidden transition-all " - > -
- {provider.name} { - // Fallback to a default image or show provider name - e.currentTarget.style.display = "none"; - e.currentTarget.parentElement!.innerHTML = `
${provider.name.substring( - 0, - 3 - )}
`; - }} - /> -
- - {provider.name} - -
-
- ))} -
-
- ); -} - -function AmountGrid({ - value, - onChange, - presetAmounts, - minAmount, - maxAmount, -}: { - value: string; - onChange: (amount: string) => void; - presetAmounts: number[]; - minAmount?: number; - maxAmount?: number; -}) { - const numericValue = parseFloat(value) || 0; - - const isBelowMin = - minAmount !== undefined && numericValue > 0 && numericValue < minAmount; - const isAboveMax = maxAmount !== undefined && numericValue > maxAmount; - - const filteredPresets = presetAmounts.filter((amount) => { - if (minAmount !== undefined && amount < minAmount) return false; - if (maxAmount !== undefined && amount > maxAmount) return false; - return true; - }); - - return ( -
-
- - {(minAmount || maxAmount) && ( - - {minAmount && `Min: ₦${minAmount.toLocaleString()}`} - {minAmount && maxAmount && " • "} - {maxAmount && `Max: ₦${maxAmount.toLocaleString()}`} - - )} -
- -
- {filteredPresets.length > 0 ? ( - filteredPresets.map((amount) => ( - onChange(amount.toString())} - className={`p-4 rounded-2xl transition-all font-medium ${ - value === amount.toString() - ? "font-black ring-2 ring-blue-500" - : "" - }`} - > - ₦{amount.toLocaleString()} - - )) - ) : ( -

- No preset amounts available -

- )} -
- -
- { - const val = e.target.value; - if (val === "" || /^\d*$/.test(val)) { - onChange(val); - } - }} - placeholder="Enter custom amount" - type="text" - inputMode="numeric" - className={`w-full p-4 rounded-2xl border-2 pr-10 transition-all ${ - isBelowMin || isAboveMax ? "border-red-500 " : "border-transparent " - } outline-none`} - /> - {(isBelowMin || isAboveMax) && ( -
- -
- )} -
- - {(isBelowMin || isAboveMax) && ( -

- {isBelowMin - ? `Amount must be at least ₦${minAmount?.toLocaleString()}` - : `Amount cannot exceed ₦${maxAmount?.toLocaleString()}`} -

- )} -
- ); -} - -function ReviewItem({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -function Button({ - onclick, - text, - disabled, - type, -}: { - onclick: () => void; - text: string; - disabled?: boolean; - type?: "secondary"; -}) { - const baseClasses = - "w-full py-4 rounded-full font-bold text-lg relative transition-all"; - const typeClasses = - type === "secondary" - ? "bg-gray-200 text-black hover:bg-gray-300" - : "0 text-white hover:bg-blue-600"; - const disabledClasses = disabled - ? "opacity-50 cursor-not-allowed" - : "cursor-pointer"; - return ( - - ); -} diff --git a/components/dashboard/stats-cards.tsx b/components/dashboard/stats-cards.tsx index 72c0ee2..83d6f20 100644 --- a/components/dashboard/stats-cards.tsx +++ b/components/dashboard/stats-cards.tsx @@ -110,10 +110,10 @@ export function StatsCards({ }); return ( -
+
{updatedStats.map((stat, index) => ( - {stat.title === "Total Balance" && ( @@ -121,7 +121,7 @@ export function StatsCards({ onClick={handleViewBalance} variant="secondary" size="sm" - className="mt-2 w-fit absolute top-0 right-2" + className="mt-2 w-fit absolute bottom-2 right-2" > {hideBalalance ? : } @@ -129,7 +129,7 @@ export function StatsCards({
-
+
@@ -144,7 +144,7 @@ export function StatsCards({ -------
) : ( -

+

{stat.value}

)} @@ -174,7 +174,7 @@ export function StatsCards({
- +
))}
); diff --git a/components/dashboard/tabs/airtime.tsx b/components/dashboard/tabs/airtime.tsx deleted file mode 100644 index 44c5dea..0000000 --- a/components/dashboard/tabs/airtime.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client" - -import React, { useEffect } from 'react' -import Purchase from '../service-flow' - -export default function Airtime() { - - const fetchProvider = async () => { - try{ - const res = await fetch("https://www.nellobytesystems.com/APIAirtimeDiscountV2.asp?UserID=CK101265322") - const data = await res.json() - console.log(data) - }catch(err){ - console.log(err) - } - } - - useEffect(() => { - fetchProvider() - }, []) - return ( - - ) -} diff --git a/components/dashboard/tabs/create-address.tsx b/components/dashboard/tabs/create-address.tsx deleted file mode 100644 index 916260c..0000000 --- a/components/dashboard/tabs/create-address.tsx +++ /dev/null @@ -1,352 +0,0 @@ -"use client"; - -import { Card } from "@/components/ui/Card"; -import { ChevronDown, Copy, Check, Loader2 } from "lucide-react"; -import React, { useState, useCallback, useEffect, ReactElement } from "react"; -import { fixStarknetAddress, shortenAddress } from "@/components/lib/utils"; -import Image from "next/image"; -import QRCodeLib from "qrcode"; -import { AddressDropdown } from "@/components/modals/addressDropDown"; -import { useWalletData } from "@/components/hooks/useWalletData"; -interface TokenOption { - // symbol: ReactElement; - name: string; - walletAddress: string; -} - -// QR code format generators for different cryptocurrencies -const generateQRData = ( - chain: string, - address: string, - amount: string | null = null, - label: string | null = null -): string => { - switch (chain.toLowerCase()) { - case "bitcoin": - case "btc": - let bitcoinUri = `bitcoin:${address}`; - const bitcoinParams = []; - if (amount) bitcoinParams.push(`amount=${amount}`); - if (label) bitcoinParams.push(`label=${encodeURIComponent(label)}`); - if (bitcoinParams.length > 0) { - bitcoinUri += `?${bitcoinParams.join("&")}`; - } - return bitcoinUri; - - case "ethereum": - case "eth": - let ethereumUri = `ethereum:${address}`; - const ethereumParams = []; - if (amount) { - const weiAmount = (parseFloat(amount) * Math.pow(10, 18)).toString(); - ethereumParams.push(`value=${weiAmount}`); - } - if (label) ethereumParams.push(`label=${encodeURIComponent(label)}`); - if (ethereumParams.length > 0) { - ethereumUri += `?${ethereumParams.join("&")}`; - } - return ethereumUri; - - case "solana": - case "sol": - let solanaUri = `solana:${address}`; - const solanaParams = []; - if (amount) solanaParams.push(`amount=${amount}`); - if (label) solanaParams.push(`label=${encodeURIComponent(label)}`); - if (solanaParams.length > 0) { - solanaUri += `?${solanaParams.join("&")}`; - } - return solanaUri; - - case "starknet": - case "strk": - let starknetUri = `starknet:${address}`; - const starknetParams = []; - if (amount) { - const weiAmount = (parseFloat(amount) * Math.pow(10, 18)).toString(); - starknetParams.push(`value=${weiAmount}`); - } - if (label) starknetParams.push(`label=${encodeURIComponent(label)}`); - if (starknetParams.length > 0) { - starknetUri += `?${starknetParams.join("&")}`; - } - return starknetUri; - - case "erc20": - return `ethereum:${address}`; - case "trc20": - return `tron:${address}`; - - default: - return address; - } -}; - -// Enhanced QR code generation function -const generateCompatibleQRCode = async ( - chain: string, - address: string, - options: { - amount?: string | null; - label?: string | null; - width?: number; - margin?: number; - darkColor?: string; - lightColor?: string; - errorCorrectionLevel?: "L" | "M" | "Q" | "H"; - } = {} -) => { - const { - amount = null, - label = null, - width = 200, - margin = 2, - darkColor = "#000000", - lightColor = "#FFFFFF", - errorCorrectionLevel = "M", - } = options; - - try { - const qrData = generateQRData(chain, address, amount, label); - - const qrCodeDataUrl = await QRCodeLib.toDataURL(qrData, { - width, - margin, - errorCorrectionLevel, - type: "image/png" as "image/png" | "image/jpeg" | "image/webp", - color: { - dark: darkColor, - light: lightColor, - }, - }); - - return { - dataUrl: qrCodeDataUrl, - rawData: qrData, - format: getQRFormat(chain), - }; - } catch (error) { - console.error("Error generating compatible QR code:", error); - throw error; - } -}; - -// Helper function to get the format description -const getQRFormat = (chain: string): string => { - switch (chain.toLowerCase()) { - case "bitcoin": - case "btc": - return "BIP21 Bitcoin URI"; - case "ethereum": - case "eth": - return "EIP681 Ethereum URI"; - case "solana": - case "sol": - return "Solana URI Scheme"; - case "starknet": - case "strk": - return "Ethereum-compatible URI"; - case "erc20": - return "ERC-20 Token URI"; - case "trc20": - return "TRC-20 Token URI"; - default: - return "Plain Address"; - } -}; - -export default function ReceiveFunds() { - const [selectedToken, setSelectedToken] = useState("starknet"); - const [showDropdown, setShowDropdown] = useState(false); - const [qrData, setQrData] = useState(""); - const [copied, setCopied] = useState(false); - const [loading, setLoading] = useState(true); - - const { addresses } = useWalletData(); - - - // Check if wallet addresses are available before rendering - useEffect(() => { - if (addresses && addresses.length > 0) { - setLoading(false); - } - }, [addresses]); - - const selectedTokenData = addresses?.find( - (token) => token.chain === selectedToken - ); - - // Generate QR code when selected token changes using enhanced function - useEffect(() => { - if (selectedTokenData && selectedTokenData?.address) { - const generateQrCode = async () => { - try { - let addressToUse = selectedTokenData?.address; - if (selectedTokenData.chain.toLowerCase() === "starknet") { - addressToUse = fixStarknetAddress( - addressToUse, - selectedTokenData.chain - ); - } - - const qrResult = await generateCompatibleQRCode( - selectedTokenData.chain, - addressToUse, - { - width: 200, - margin: 2, - errorCorrectionLevel: "M", - } - ); - - setQrData(qrResult.dataUrl); - } catch (error) { - console.error("Error generating QR code:", error); - } - }; - - generateQrCode(); - } - }, [selectedTokenData]); - - const handleTokenSelect = useCallback((symbol: string) => { - setSelectedToken(symbol); - setShowDropdown(false); - }, []); - - const handleCopyAddress = useCallback(async () => { - if (!selectedTokenData) return; - - try { - await navigator.clipboard.writeText( - fixStarknetAddress(selectedTokenData?.address, selectedTokenData.chain) - ); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy address: ", err); - } - }, [selectedTokenData]); - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = () => { - if (showDropdown) { - setShowDropdown(false); - } - }; - - document.addEventListener("click", handleClickOutside); - return () => { - document.removeEventListener("click", handleClickOutside); - }; - }, [showDropdown]); - - // Show loading state while addresses are being fetched - if (addresses.length < 1) { - return ( -
-
- -

Loading wallet addresses...

-
-
- ); - } - - // Show error state if no addresses are available - if (!addresses || addresses.length === 0) { - return ( -
- -

- No Wallet Addresses -

-

- Unable to retrieve wallet addresses. Please check your connection - and try again. -

-
-
- ); - } - - return ( -
-
- - {/* Header */} -
-

Receive Funds

-

- Select a currency and share your address to receive payments -

-
- - {/* Token Selector */} - - - - {/* QR Code */} -
- {qrData ? ( -
- QR Code -
- ) : ( -
- Loading QR... -
- )} - -
-

- {selectedToken} Address -

-
-

- {selectedTokenData?.address - ? shortenAddress( - fixStarknetAddress( - selectedTokenData.address, - selectedTokenData.chain - ), - 10 - ) - : ""} -

- -
-
-
- - {/* Instructions */} -
- - -

- How to receive funds -

-
    -
  • Select the currency you want to receive
  • -
  • Share your QR code or wallet address
  • -
  • Wait for the sender to complete the transaction
  • -
  • Funds will appear in your wallet after confirmation
  • -
-
-
-
- ); -} diff --git a/components/dashboard/tabs/dashboard.tsx b/components/dashboard/tabs/dashboard.tsx deleted file mode 100644 index 5878cfc..0000000 --- a/components/dashboard/tabs/dashboard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { Card } from "@/components/ui/Card"; -import Button from "@/components/ui/Button"; -import { StatsCards } from "../stats-cards"; -import { QuickActions } from "../quick-actions"; -import { RecentActivity } from "../recent-activity"; -import { WalletOverview } from "../wallet-overview"; -import { useAuth } from "@/components/context/AuthContext"; -import { useState } from "react"; - -interface RecentActivity { - id: string; - type: "incoming" | "outgoing" | "swap" | "split"; - amount: string; - token: string; - description: string; - timestamp: string; - status: "completed" | "pending" | "failed"; -} - -export interface DashboardProps { - activeTab: React.Dispatch>; -} - -export default function DashboardHome({ activeTab }: DashboardProps) { - const { user } = useAuth(); - - const [hideBalalance, setHideBalance] = useState(false); - - const handleViewBalance = () => { - setHideBalance(!hideBalalance); - }; - - return ( -
- {/* Header */} -
-

- Welcome back, {user?.firstName?.toLocaleUpperCase()} -

-

- {"Ready to manage your finances? Let's make some magic happen."} -

-
- - {/* Stats Grid */} - - - {/* Quick Actions */} - - - {/* Main Content Grid */} -
-
- -
-
- {" "} -
-
- - {/* Bottom CTA */} - -
-
-

Need Help?

-

- Check our Help or contact support -

-
- -
- - -
-
-
-
- ); -} diff --git a/components/dashboard/tabs/data.tsx b/components/dashboard/tabs/data.tsx deleted file mode 100644 index 4000e8e..0000000 --- a/components/dashboard/tabs/data.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react' -import Purchase from '../service-flow' - -export default function Data() { - return ( - - ) -} diff --git a/components/dashboard/tabs/electricity.tsx b/components/dashboard/tabs/electricity.tsx deleted file mode 100644 index da29901..0000000 --- a/components/dashboard/tabs/electricity.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client" - -import React, { useEffect } from 'react' -import Purchase from '../service-flow' - -export default function Electricity() { - - const fetchProvider = async () => { - try{ - const res = await fetch("https://www.nellobytesystems.com/APIAirtimeDiscountV2.asp?UserID=CK101265322") - const data = await res.json() - console.log(data) - }catch(err){ - console.log(err) - } - } - - useEffect(() => { - fetchProvider() - }, []) - return ( - - ) -} diff --git a/components/dashboard/tabs/logout.tsx b/components/dashboard/tabs/logout.tsx deleted file mode 100644 index 5df1ea2..0000000 --- a/components/dashboard/tabs/logout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useAuth } from "@/components/context/AuthContext"; -import React from "react"; - -export default function Logout() { - const { logout } = useAuth(); - - - return ( -
-
-

- Are you sure you want to logout? -

- -
-
- ); -} diff --git a/components/dashboard/tabs/profile.tsx b/components/dashboard/tabs/profile.tsx deleted file mode 100644 index 51dae77..0000000 --- a/components/dashboard/tabs/profile.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import EditProfile from "@/components/modals/edit-profile"; -import React from "react"; - -export default function Profile() { - return
- -
; -} diff --git a/components/dashboard/tabs/qr-payment.tsx b/components/dashboard/tabs/qr-payment.tsx deleted file mode 100644 index 0d041ca..0000000 --- a/components/dashboard/tabs/qr-payment.tsx +++ /dev/null @@ -1,541 +0,0 @@ -"use client"; - -import { Card } from "@/components/ui/Card"; -import { ChevronDown } from "lucide-react"; -import { useCallback, useMemo, useState, useEffect } from "react"; -import Image from "next/image"; -import QRCodeLib from "qrcode"; -import useExchangeRates from "@/components/hooks/useExchangeRate"; -import { QRCodeDisplay } from "@/components/modals/qr-code-display"; -import { useMerchantPayments } from "@/components/hooks/useMerchantPayments"; // ADD -import { normalizeStarknetAddress } from "@/components/lib/utils"; -import { AddressDropdown } from "@/components/modals/addressDropDown"; -import { useWalletData } from "@/components/hooks/useWalletData"; - -const generateQRData = ( - chain: string, - address: string, - amount: string | null = null, - label: string | null = null -): string => { - switch (chain.toLowerCase()) { - case "bitcoin": - case "btc": - let bitcoinUri = `bitcoin:${address}`; - const bitcoinParams = []; - if (amount) bitcoinParams.push(`amount=${amount}`); - if (label) bitcoinParams.push(`label=${encodeURIComponent(label)}`); - if (bitcoinParams.length > 0) { - bitcoinUri += `?${bitcoinParams.join("&")}`; - } - return bitcoinUri; - - case "ethereum": - case "eth": - let ethereumUri = `ethereum:${address}`; - const ethereumParams = []; - if (amount) { - const weiAmount = (parseFloat(amount) * Math.pow(10, 18)).toString(); - ethereumParams.push(`value=${weiAmount}`); - } - if (label) ethereumParams.push(`label=${encodeURIComponent(label)}`); - if (ethereumParams.length > 0) { - ethereumUri += `?${ethereumParams.join("&")}`; - } - return ethereumUri; - - case "solana": - case "sol": - let solanaUri = `solana:${address}`; - const solanaParams = []; - if (amount) solanaParams.push(`amount=${amount}`); - if (label) solanaParams.push(`label=${encodeURIComponent(label)}`); - if (solanaParams.length > 0) { - solanaUri += `?${solanaParams.join("&")}`; - } - return solanaUri; - - case "starknet": - case "strk": - let starknetUri = `starknet:${address}`; - const starknetParams = []; - if (amount) { - const weiAmount = (parseFloat(amount) * Math.pow(10, 18)).toString(); - starknetParams.push(`value=${weiAmount}`); - } - if (label) starknetParams.push(`label=${encodeURIComponent(label)}`); - if (starknetParams.length > 0) { - starknetUri += `?${starknetParams.join("&")}`; - } - return starknetUri; - - case "stellar": - case "xlm": - let stellarUri = `web+stellar:pay?destination=${address}`; - const stellarParams = []; - if (amount) { - // Stellar amounts are in lumens (1 XLM = 1,000,000 stroops) - // For QR codes, we typically use the base unit (lumens) - stellarParams.push(`amount=${amount}`); - } - if (label) stellarParams.push(`memo=${encodeURIComponent(label)}`); - if (stellarParams.length > 0) { - stellarUri += `&${stellarParams.join("&")}`; - } - return stellarUri; - - case "polkadot": - case "dot": - let polkadotUri = `substrate:${address}`; - const polkadotParams = []; - if (amount) { - // Polkadot amounts are in Planck (1 DOT = 10,000,000,000 Planck) - // For QR codes, we typically use the base unit (DOT) - polkadotParams.push(`amount=${amount}`); - } - if (label) polkadotParams.push(`label=${encodeURIComponent(label)}`); - if (polkadotParams.length > 0) { - polkadotUri += `?${polkadotParams.join("&")}`; - } - return polkadotUri; - - case "usdt_erc20": - return `ethereum:${address}`; - case "usdt_trc20": - return `tron:${address}`; - - default: - return address; - } -}; - -// Enhanced QR code generation function -const generateCompatibleQRCode = async ( - chain: string, - address: string, - options: { - amount?: string | null; - label?: string | null; - width?: number; - margin?: number; - darkColor?: string; - lightColor?: string; - errorCorrectionLevel?: "L" | "M" | "Q" | "H"; - } = {} -) => { - const { - amount = null, - label = null, - width = 200, - margin = 2, - darkColor = "#000000", - lightColor = "#FFFFFF", - errorCorrectionLevel = "M", - } = options; - - try { - const qrData = generateQRData(chain, address, amount, label); - const qrCodeDataUrl = await QRCodeLib.toDataURL(qrData, { - width, - margin, - errorCorrectionLevel, - type: "image/png" as "image/png" | "image/jpeg" | "image/webp", - color: { - dark: darkColor, - light: lightColor, - }, - }); - - return { - dataUrl: qrCodeDataUrl, - rawData: qrData, - format: getQRFormat(chain), - }; - } catch (error) { - console.error("Error generating compatible QR code:", error); - throw error; - } -}; - -// Helper function to get the format description -const getQRFormat = (chain: string): string => { - switch (chain.toLowerCase()) { - case "bitcoin": - case "btc": - return "BIP21 Bitcoin URI"; - case "ethereum": - case "eth": - return "EIP681 Ethereum URI"; - case "solana": - case "sol": - return "Solana URI Scheme"; - case "starknet": - case "strk": - return "Ethereum-compatible URI"; - case "stellar": - case "xlm": - return "Stellar URI Scheme"; - case "polkadot": - case "dot": - return "Polkadot URI Scheme"; - case "usdt_erc20": - return "ERC-20 Token URI"; - case "usdt_trc20": - return "TRC-20 Token URI"; - default: - return "Plain Address"; - } -}; -export default function QrPayment() { - const [token, setToken] = useState("STARKNET"); - const [amount, setAmount] = useState(""); - const [customerEmail, setCustomerEmail] = useState(""); - const [description, setDescription] = useState(""); - const [showQR, setShowQR] = useState(false); - const [qrData, setQrData] = useState(""); - const [isProcessing, setIsProcessing] = useState(false); - const [paymentId, setPaymentId] = useState(""); - const [localError, setLocalError] = useState(null); - const [paymentStatus, setPaymentStatus] = useState(""); - const { addresses } = useWalletData(); - - const tokenRate = (token: string) => { - if (token === "ETHEREUM") return "ETH"; - if (token === "BITCOIN") return "BTC"; - if (token === "SOLANA") return "SOL"; - if (token === "STARKNET") return "STRK"; - if (token === "USDT_TRC20") return "USDT"; - if (token === "USDT_ERC20") return "USDT"; - if (token === "POLKADOT") return "DOT"; - if (token === "STELLAR") return "XML"; - return "USDT"; - }; - const { rates, isLoading: ratesLoading } = useExchangeRates(); - const { createPayment, isLoading: merchantLoading } = useMerchantPayments(); - - const getTokenChain = useCallback((): string => { - const chainMap: { [key: string]: string } = { - ETHEREUM: "ethereum", - BITCOIN: "bitcoin", - SOLANA: "solana", - STARKNET: "starknet", - USDT_ERC20: "usdt_erc20", - USDT_TRC20: "usdt_trc20", - POLKADOT: "polkadot", - STELLAR: "stellar", - }; - return chainMap[token] || "ethereum"; - }, [token]); - - const singleAddress = addresses.filter( - (a) => a.chain === token.toLowerCase() - ); - - const currentReceiverAddress = useMemo((): string => { - if (!addresses || addresses.length === 0) return ""; - - const chain = getTokenChain(); - const addr = addresses.find((a) => a.chain === chain); - if (!addr) return ""; - - return normalizeStarknetAddress(addr.address, chain); - }, [addresses, getTokenChain]); - - const calculateTokenAmount = useCallback((): string => { - const ngnAmount = parseFloat(amount) || 0; - const rateKey = tokenRate(token); - const rate = rateKey ? rates[rateKey] : 1; - - if (!rate || rate === 0) { - console.log("Using fallback rate for calculation"); - return (ngnAmount / 1500).toFixed(6); - } - - const tokenAmount = ngnAmount / rate; - - return tokenAmount.toFixed(6); - }, [amount, rates, token]); - - const handleTokenSelect = (tkn: string) => { - setToken(tkn.toUpperCase()); - }; - - const handleCreatePaymentRequest = async () => { - if (!amount || !currentReceiverAddress) { - setLocalError( - "Please enter an amount and ensure wallet address is available" - ); - return; - } - - setIsProcessing(true); - setLocalError(null); - - try { - const tokenAmount = calculateTokenAmount(); - const chain = getTokenChain(); - - const qrResult = await generateCompatibleQRCode( - chain, - currentReceiverAddress, - { - amount: tokenAmount, - width: 200, - margin: 2, - errorCorrectionLevel: "M", - } - ); - - const requestBody: any = { - amount: parseFloat(tokenAmount), - chain: chain, - network: "mainnet", - description: description || "QRPayment request", - }; - - switch (chain.toLowerCase()) { - case "bitcoin": - requestBody.btcAddress = currentReceiverAddress; - break; - case "ethereum": - requestBody.ethAddress = currentReceiverAddress; - break; - case "solana": - requestBody.solAddress = currentReceiverAddress; - break; - case "starknet": - requestBody.strkAddress = currentReceiverAddress; - break; - case "usdt_erc20": - requestBody.usdtErc20Address = currentReceiverAddress; - break; - case "usdt_trc20": - requestBody.usdtTrc20Address = currentReceiverAddress; - break; - case "polkadot": - requestBody.dotAddress = currentReceiverAddress; - break; - case "stellar": - requestBody.xmlAddress = currentReceiverAddress; - break; - default: - requestBody.address = currentReceiverAddress; - } - - const response = await createPayment(requestBody); - - - if (response && response.payment) { - setPaymentId(response.payment.id || ""); - setPaymentStatus(response.payment.status); - setQrData(qrResult.dataUrl); - setShowQR(true); - } else { - throw new Error("Invalid response from server - no payment data"); - } - } catch (error: any) { - console.error("Error creating payment request:", error); - setLocalError(error.message || "Failed to create payment request"); - } finally { - setIsProcessing(false); - } - }; - - const handleCloseQR = () => { - setShowQR(false); - setQrData(""); - setPaymentId(""); - setLocalError(null); - setAmount(""); - }; - - const steps = [ - { - step: "Enter Amount", - description: "Specify the amount in NGN you want to receive", - }, - { - step: "Generate QR", - description: "Create a unique payment request QR code", - }, - { - step: "Share QR", - description: "Send the QR code or address to the payer", - }, - { - step: "Receive Payment", - description: "Funds will be credited after confirmation", - }, - ]; - - if (addresses.length === 0) { - return ( -
-
Loading wallet data...
-
- ); - } - - return ( -
-
- -

- Create Payment Request -

- -
-
- {/* Token Selection */} - - {/* Amount Input */} -
- - setAmount(e.target.value)} - placeholder="Enter amount in NGN" - className="w-full p-3 rounded-lg bg-background border border-border placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors" - min="0" - step="any" - disabled={ - merchantLoading || - ratesLoading || - isProcessing - } - /> -

- ≈ {calculateTokenAmount()} {token} -

-
-
- - setDescription(e.target.value)} - placeholder="Describe the payment purpose" - className="w-full p-3 rounded-lg bg-background border border-border placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors" - min="0" - step="any" - disabled={ - merchantLoading || - ratesLoading || - isProcessing - } - /> -
-
- - setCustomerEmail(e.target.value)} - placeholder="Enter customer email" - className="w-full p-3 rounded-lg bg-background border border-border placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors" - min="0" - step="any" - disabled={ - merchantLoading || - ratesLoading || - isProcessing - } - /> -
-
- - {/* Error Message */} - {localError && !showQR && ( -
-

{localError}

-
- )} - - {/* Generate Button */} - -
-
- - {/* Instructions */} - -

- How to Accept Payments -

-
- {steps.map((step, id) => ( -
-
- - {id + 1} - -
-
-

{step.step}

-

- {step.description} -

-
-
- ))} -
-
-
- - {/* QR Code Modal */} - {showQR && ( - - )} -
- ); -} diff --git a/components/dashboard/tabs/send-funds.tsx b/components/dashboard/tabs/send-funds.tsx deleted file mode 100644 index 31673d6..0000000 --- a/components/dashboard/tabs/send-funds.tsx +++ /dev/null @@ -1,589 +0,0 @@ -"use client"; - -import { Card } from "@/components/ui/Card"; -import { - Loader2, - ArrowUpRight, - Check, - TriangleAlert, - Copy, - CheckCheck, - AlertCircle, -} from "lucide-react"; -import React, { useState, useCallback, useEffect, useMemo } from "react"; -import { useAuth } from "@/components/context/AuthContext"; -import { shortenAddress } from "@/components/lib/utils"; -import useExchangeRates from "@/components/hooks/useExchangeRate"; -import { AddressDropdown } from "@/components/modals/addressDropDown"; -import { useWalletData } from "@/components/hooks/useWalletData"; -import { useTokenBalance } from "@/components/hooks"; -import { TransactionPinDialog } from "@/components/ui/transaction-pin-dialog"; - - -interface TokenOption { - symbol: string; - name: string; - chain: string; - network: string; - address: string; - hasWallet: boolean; -} - -export default function SendFunds() { - const [selectedToken, setSelectedToken] = useState("ethereum"); - const [showTokenDropdown, setShowTokenDropdown] = useState(false); - const [toAddress, setToAddress] = useState(""); - const [amount, setAmount] = useState(""); - const [copied, setCopied] = useState(false); - const [isSending, setIsSending] = useState(false); - const [showPinDialog, setShowPinDialog] = useState(false); - const [txStatus, setTxStatus] = useState<{ - type: "success" | "error" | null; - message: string; - txHash?: string; - }>({ type: null, message: "" }); - - const { sendTransaction } = useAuth(); - const { rates } = useExchangeRates(); - const { - addresses, - balances, - breakdown, - } = useWalletData(); - const { getTokenSymbol, getTokenName } = useTokenBalance(); - - // Token options based on available addresses - const tokenOptions: TokenOption[] = useMemo(() => { - if (!addresses) return []; - - return addresses.map((addr) => { - const balanceInfo = balances.find((b) => b.chain === addr.chain); - return { - symbol: getTokenSymbol(addr.chain), - name: getTokenName(addr.chain), - chain: addr.chain, - network: addr.network, - address: addr.address, - hasWallet: true, - balance: parseFloat(balanceInfo?.balance || "0"), - }; - }); - }, [addresses, balances]); // ADD balances dependency - // Get token symbol - - // Normalize and validate Starknet address - const normalizeStarknetAddress = (address: string): string => { - // Remove whitespace - let normalized = address.trim(); - - // Add 0x prefix if missing - if (!normalized.startsWith("0x")) { - normalized = "0x" + normalized; - } - - // Remove 0x for validation and padding - const hexPart = normalized.slice(2); - - // Validate hex characters only - if (!/^[0-9a-fA-F]*$/.test(hexPart)) { - throw new Error( - "Address contains invalid characters. Only hexadecimal characters (0-9, a-f, A-F) are allowed." - ); - } - - // Pad to 64 characters (without 0x) - const paddedHex = hexPart.padStart(64, "0"); - - // Check if address is too long after padding - if (paddedHex.length > 64) { - throw new Error( - "Address is too long. Maximum length is 66 characters (including 0x prefix)." - ); - } - - return "0x" + paddedHex; - }; - - const selectedTokenData = tokenOptions.find( - (token) => token.chain === selectedToken - ); - - // Get current wallet balance for selected token - const currentWalletBalance = useMemo(() => { - const balanceInfo = balances.find((b) => b.chain === selectedToken); - return parseFloat(balanceInfo?.balance || "0"); - }, [balances, selectedToken]); - // Get current wallet address and network for selected token - const currentWalletAddress = useMemo(() => { - if (!addresses) return ""; - const addressInfo = addresses.find((addr) => addr.chain === selectedToken); - return addressInfo?.address || ""; - }, [addresses, selectedToken]); - - const currentNetwork = useMemo(() => { - if (!addresses) return "mainnet"; - const addressInfo = addresses.find((addr) => addr.chain === selectedToken); - return addressInfo?.network || "mainnet"; - }, [addresses, selectedToken]); - - // Check if selected token has a wallet - const hasWalletForSelectedToken = useMemo(() => { - return !!currentWalletAddress; - }, [currentWalletAddress]); - - // Calculate NGN equivalent - const ngnEquivalent = useMemo(() => { - if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) - return 0; - - const tokenSymbol = getTokenSymbol(selectedToken); - const tokenRate = rates[tokenSymbol as keyof typeof rates] || 1; - return parseFloat(amount) * tokenRate; - }, [amount, selectedToken, rates]); - - // Validation - const validationError = useMemo(() => { - if (!hasWalletForSelectedToken) { - return "No wallet found for this currency"; - } - if (!toAddress.trim()) { - return "Recipient address is required"; - } - if (!amount || parseFloat(amount) <= 0) { - return "Amount must be greater than 0"; - } - if (parseFloat(amount) > currentWalletBalance) { - return "Insufficient balance"; - } - return null; - }, [hasWalletForSelectedToken, toAddress, amount, currentWalletBalance]); - - // Reset form - const resetForm = useCallback(() => { - setToAddress(""); - setAmount(""); - setTxStatus({ type: null, message: "" }); - }, []); - - // Handle token selection - const handleTokenSelect = useCallback( - (chain: string) => { - setSelectedToken(chain); - setShowTokenDropdown(false); - resetForm(); - }, - [resetForm] - ); - - // Copy address to clipboard - const handleCopyAddress = useCallback(async () => { - if (!currentWalletAddress) return; - - try { - await navigator.clipboard.writeText(currentWalletAddress); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy address: ", err); - } - }, [currentWalletAddress]); - - // Handle send transaction - const handleSendWithPin = async (pin: string) => { - if (validationError) { - setTxStatus({ - type: "error", - message: validationError, - }); - setShowPinDialog(false); - return; - } - - setIsSending(true); - setTxStatus({ type: null, message: "" }); - - try { - let normalizedToAddress = toAddress.trim(); - let normalizedFromAddress = currentWalletAddress.trim(); - - // Special handling for Starknet addresses - if (selectedToken === "starknet") { - try { - normalizedToAddress = normalizeStarknetAddress(toAddress); - normalizedFromAddress = - normalizeStarknetAddress(currentWalletAddress); - console.log("Normalized Starknet address:", normalizedToAddress); - } catch (error) { - throw new Error( - error instanceof Error - ? `Invalid Starknet address: ${error.message}` - : "Invalid Starknet address format" - ); - } - } - - - // Include PIN in the transaction payload - const response = await sendTransaction({ - chain: selectedToken, - network: currentNetwork, - toAddress: normalizedToAddress, - amount: amount, - fromAddress: currentWalletAddress, - transactionPin: pin, - }); - - setTxStatus({ - type: "success", - message: "Transaction sent successfully!", - txHash: response.txHash, - }); - - // Close PIN dialog - setShowPinDialog(false); - - // Reset form after 10 seconds - setTimeout(() => { - resetForm(); - }, 10000); - } catch (error: any) { - console.error("Transaction error:", error); - - let errorMessage = "Failed to send transaction. Please try again."; - - if (error.message) { - errorMessage = error.message; - } else if (typeof error === "string") { - errorMessage = error; - } - - setTxStatus({ - type: "error", - message: errorMessage, - }); - - // Close PIN dialog on error - setShowPinDialog(false); - } finally { - setIsSending(false); - } - }; - - // Modified handleSendTransaction to show PIN dialog - const handleSendTransaction = () => { - if (validationError) { - setTxStatus({ - type: "error", - message: validationError, - }); - return; - } - - // Show PIN dialog instead of immediately sending - setShowPinDialog(true); - }; - - // Handle PIN dialog close - const handlePinDialogClose = () => { - setShowPinDialog(false); - }; - - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = () => { - if (showTokenDropdown) { - setShowTokenDropdown(false); - } - }; - - document.addEventListener("click", handleClickOutside); - return () => { - document.removeEventListener("click", handleClickOutside); - }; - }, [showTokenDropdown]); - - // Format balance display - const formatBalance = (balance: number): string => { - if (balance === 0) return "0.00"; - if (balance < 0.001) return "<0.001"; - return balance.toFixed(4); - }; - - // Format NGN currency - const formatNGN = (amount: number): string => { - return new Intl.NumberFormat("en-NG", { - style: "currency", - currency: "NGN", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(amount); - }; - - // Get block explorer URL - const getExplorerUrl = (txHash: string): string => { - const explorerUrls: { - [key: string]: { testnet: string; mainnet: string }; - } = { - ethereum: { - testnet: `https://sepolia.etherscan.io/tx/${txHash}`, - mainnet: `https://etherscan.io/tx/${txHash}`, - }, - usdt_erc20: { - testnet: `https://sepolia.etherscan.io/tx/${txHash}`, - mainnet: `https://etherscan.io/tx/${txHash}`, - }, - bitcoin: { - testnet: `https://blockstream.info/testnet/tx/${txHash}`, - mainnet: `https://blockstream.info/tx/${txHash}`, - }, - solana: { - testnet: `https://explorer.solana.com/tx/${txHash}?cluster=devnet`, - mainnet: `https://explorer.solana.com/tx/${txHash}`, - }, - starknet: { - testnet: `https://sepolia.voyager.online/tx/${txHash}`, - mainnet: `https://voyager.online/tx/${txHash}`, - }, - }; - - const explorer = explorerUrls[selectedToken]; - if (!explorer) return "#"; - - return currentNetwork === "testnet" ? explorer.testnet : explorer.mainnet; - - }; - - - - if (addresses.length === 0) { - return ( -
-
- -

Loading wallet data...

-
-
- ); - } - - if (!addresses || addresses.length === 0) { - return ( -
- -

- No Wallets Available -

-

- No Velo wallets found. Please create wallets first to send funds. -

-
-
- ); - } - - return ( -
-
- - {/* Header */} -
-

Send Payment

-

- Transfer funds from your Velo wallet to any valid address -

-
- -
- {/* Transaction Status */} - {txStatus.type && ( -
-
- {txStatus.type === "success" ? ( - - ) : ( - - )} - - {txStatus.type === "success" ? "Success" : "Error"} - -
-

- {txStatus.message} -

- {txStatus.txHash && ( - - View on Explorer - - - )} -
- )} - - {/* Wallet Status Warning */} - {!hasWalletForSelectedToken && ( -
-
- - No Wallet Found -
-

- No Velo wallet found for {getTokenName(selectedToken)}. You can - only send from wallets created in Velo. -

-
- )} - -
-
- - - {/* Selected Wallet Address */} - {currentWalletAddress && ( -
-
- From Address: - -
-

{shortenAddress(currentWalletAddress, 8)}

-

Network: {currentNetwork}

-
- )} -
- -
- {/* Recipient Address */} -
- - setToAddress(e.target.value)} - placeholder={`Enter ${selectedTokenData?.name || selectedToken} address`} - className="w-full p-3 rounded-lg bg-background border border-border placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors font-mono text-sm" - disabled={!hasWalletForSelectedToken || isSending} - /> - {selectedToken === "starknet" && toAddress && ( -

Tip: Address will be automatically formatted with 0x prefix and proper padding

- )} -
- - {/* Amount */} -
- -
- { - const value = e.target.value; - if (/^\d*\.?\d*$/.test(value)) { - setAmount(value); - } - }} - placeholder="0.00" - className="w-full p-3 rounded-lg bg-background border border-border placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors pr-20 disabled:opacity-50" - disabled={!hasWalletForSelectedToken || isSending} - /> -
{selectedTokenData?.symbol}
-
-
- Available: {formatBalance(currentWalletBalance)} {selectedTokenData?.symbol} - {ngnEquivalent > 0 && ≈ {formatNGN(ngnEquivalent)}} -
-
-
-
- - {/* Send Button */} -
- -
- - {/* Network Info */} -
-

Sending on {currentNetwork} network

-
-
- - {/* Instructions */} -
- - -

Important Notes

-
    -
  • Recipient does NOT need to be a Velo user
  • -
  • Only send to valid addresses for the selected currency
  • -
  • Transactions are irreversible once confirmed
  • -
  • Double-check addresses before sending
  • - {selectedToken === "starknet" && ( - <> -
  • - Starknet wallets may need deployment (auto-handled) -
  • -
  • - Addresses will be auto-formatted with 0x prefix and padding -
  • - - )} -
-
- - -
-
- ); -} diff --git a/components/dashboard/tabs/services.tsx b/components/dashboard/tabs/services.tsx deleted file mode 100644 index 88b8d0c..0000000 --- a/components/dashboard/tabs/services.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React, { useState } from "react"; -import { PhoneCall, Wifi, Tv, Search, Zap, Lightbulb } from "lucide-react"; -import Airtime from "./airtime"; -import Data from "./data"; -import Electricity from "./electricity"; - -const TABS = [ - { key: "Airtime", label: "Airtime", icon: PhoneCall }, - { key: "Data", label: "Data", icon: Wifi }, - { key: "Electricity", label: "Electricity", icon: Lightbulb }, -]; - -export default function Services() { - const [activeTab, setActiveTab] = useState("Airtime"); - const ActiveIcon = TABS.find((t) => t.key === activeTab)?.icon || PhoneCall; - - return ( -
-
- {/* Left info / quick actions - */} - - {/* Main content */} -
-
-
-
- -
-
-

{activeTab}

-

- {activeTab === "Airtime" - ? "Quick airtime purchases" - : activeTab === "Data" - ? "Flexible data plans" - : "Cable subscriptions"} -

-
-
- -
- Step -
- 1 of 3 -
-
-
- -
-
- {TABS.map((tab) => ( - - ))} -
-
- -
- {activeTab === "Airtime" && } - {activeTab === "Data" && } - {activeTab === "Electricity" && } -
-
-
-
- ); -} diff --git a/components/dashboard/top-nav.tsx b/components/dashboard/top-nav.tsx index 734dad1..4861d4c 100644 --- a/components/dashboard/top-nav.tsx +++ b/components/dashboard/top-nav.tsx @@ -7,6 +7,7 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { ThemeToggle } from "@/components/ui/theme-toggle"; import Notification from "@/components/ui/notification"; import { Card } from "@/components/ui/Card"; +import Link from "next/link"; // import { useDeposits } from "../hooks"; interface DashboardHeaderProps { @@ -14,11 +15,11 @@ interface DashboardHeaderProps { setTab: React.Dispatch>; } -export function TopNav({ tabTitle, setTab }: DashboardHeaderProps) { +export function TopNav() { // const { checkDeposits } = useDeposits(); -const [searchOpen, setSearchOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); -// Automatically start checking deposits when component mounts + // Automatically start checking deposits when component mounts // const checkRef = useRef(checkDeposits); // useEffect(() => { // checkRef.current = checkDeposits; @@ -34,61 +35,54 @@ const [searchOpen, setSearchOpen] = useState(false); // return () => window.clearInterval(id); // }, []); -; - const isHelp = tabTitle === "Help"; + // const isHelp = tabTitle === "Help"; return (
{/* Actions */} -
- {tabTitle} -
-
- {/* Desktop search */} -
- - -
- - {/* Mobile search sheet */} - - - - - -
- - -
-
-
- - +
- + {/* Desktop search */} + + + + + +
+ + +
+
+
+ +
+ + +
- - - + {/* Mobile search sheet */} +
+ +
+ +
+ + + + + +
diff --git a/components/dashboard/wallet-overview.tsx b/components/dashboard/wallet-overview.tsx index 1671cf1..0dfae6f 100644 --- a/components/dashboard/wallet-overview.tsx +++ b/components/dashboard/wallet-overview.tsx @@ -22,7 +22,7 @@ export function WalletOverview({ hideBalalance, }: WalletOverviewProps) { const { addresses, breakdown } = useWalletData(); - + const [copiedAddress, setCopiedAddress] = useState(null); const walletData = (() => { @@ -33,8 +33,10 @@ export function WalletOverview({ if (k === "eth" || k === "ethereum") return "ethereum"; if (k === "btc" || k === "bitcoin") return "bitcoin"; if (k === "strk" || k === "starknet") return "starknet"; - if (k === "usdt" || k === "usdt_erc20" || k === "usdt-erc20") return "usdt_erc20"; - if (k === "usdt_trc20" || k === "usdt-trc20" || k === "usdttrc20") return "usdt_trc20"; + if (k === "usdt" || k === "usdt_erc20" || k === "usdt-erc20") + return "usdt_erc20"; + if (k === "usdt_trc20" || k === "usdt-trc20" || k === "usdttrc20") + return "usdt_trc20"; if (k === "dot" || k === "polkadot") return "polkadot"; if (k === "xlm" || k === "stellar") return "stellar"; return k; @@ -102,52 +104,30 @@ export function WalletOverview({ ); return ( - - - - Wallet Overview - - - - - + // + + {walletData?.map((wallet, index) => (
-
-
- {wallet.chain} { - (e.target as HTMLImageElement).style.display = "none"; - ( - e.target as HTMLImageElement - ).nextElementSibling?.classList.remove("hidden"); - }} - /> - - {wallet.chain.charAt(0)} - -
-
-

- {wallet.chain} -

-

- {shortenAddress(wallet.address as `0x${string}`, 6)} -

+
+
+
+ {wallet.chain} { + (e.target as HTMLImageElement).style.display = "none"; + ( + e.target as HTMLImageElement + ).nextElementSibling?.classList.remove("hidden"); + }} + /> +
{walletData.length === 0 ? ( ) : ( @@ -156,6 +136,15 @@ export function WalletOverview({

)}
+ +
+ {/*

+ {wallet.chain} +

+

+ {shortenAddress(wallet.address as `0x${string}`, 6)} +

*/} +
@@ -173,53 +162,12 @@ export function WalletOverview({ {formatNGN(wallet.ngnValue)}

)} - -
- - -
)}
))} - - {/* Total Balance Summary */} - {breakdown.length > 0 && ( -
-
- Total Value: - - {formatNGN( - breakdown.reduce((sum, item) => sum + item.ngnValue, 0) - )} - -
-
- )} - + // ); -} \ No newline at end of file +} diff --git a/components/hooks/use-transaction-pin.ts b/components/hooks/use-transaction-pin.ts deleted file mode 100644 index 7713c06..0000000 --- a/components/hooks/use-transaction-pin.ts +++ /dev/null @@ -1,117 +0,0 @@ -// import { useCallback, useState } from 'react'; -// import { apiClient } from '@/lib/api-client'; -// import { toast } from 'sonner'; - -// interface TransactionPinHook { -// // State -// isPinDialogOpen: boolean; -// isProcessing: boolean; - -// // Methods -// requestTransactionPin: (transactionData: any) => Promise<{ -// success: boolean; -// message: string; -// data?: any; -// }>; -// openPinDialog: () => void; -// closePinDialog: () => void; -// handlePinSubmit: (pin: string) => Promise; -// } - -// export const useTransactionPin = (): TransactionPinHook => { -// const [isPinDialogOpen, setIsPinDialogOpen] = useState(false); -// const [isProcessing, setIsProcessing] = useState(false); -// const [pendingTransaction, setPendingTransaction] = useState(null); -// const [transactionResolver, setTransactionResolver] = useState<((value: any) => void) | null>(null); - -// // Request PIN for transaction (returns a promise) -// const requestTransactionPin = useCallback((transactionData: any): Promise => { -// return new Promise((resolve) => { -// setPendingTransaction(transactionData); -// setTransactionResolver(() => resolve); -// setIsPinDialogOpen(true); -// }); -// }, []); - -// // Handle PIN submission and transaction execution -// const handlePinSubmit = useCallback(async (pin: string) => { -// if (!pendingTransaction) return; - -// setIsProcessing(true); - -// try { -// // Step 1: Verify the PIN first -// const pinVerification = await apiClient.TransactionPin(pin); - -// if (!pinVerification.success) { -// throw new Error(pinVerification.message || 'Invalid PIN'); -// } - -// // Step 2: If PIN is valid, execute the transaction with PIN included -// let transactionResult; - -// switch (pendingTransaction.type) { -// case 'send': -// // Include PIN in the transaction data for backend signing -// const transactionWithPin = { -// ...pendingTransaction, -// transactionPin: pin // ADD PIN to transaction data -// }; -// transactionResult = await apiClient.sendTransaction(transactionWithPin); -// break; -// case 'split-payment': -// transactionResult = await apiClient.executeSplitPayment(pin); -// break; -// default: -// throw new Error('Unknown transaction type'); -// } - -// // Step 3: Return success result -// if (transactionResolver) { -// transactionResolver({ -// success: true, -// message: 'Transaction completed successfully', -// data: transactionResult -// }); -// } - -// toast.success('Transaction completed successfully'); -// closePinDialog(); - -// } catch (error: any) { -// const errorMessage = error.message || 'Transaction failed'; - -// if (transactionResolver) { -// transactionResolver({ -// success: false, -// message: errorMessage -// }); -// } - -// toast.error(errorMessage); -// closePinDialog(); -// } finally { -// setIsProcessing(false); -// } -// }, [pendingTransaction, transactionResolver]); - -// const openPinDialog = useCallback(() => { -// setIsPinDialogOpen(true); -// }, []); - -// const closePinDialog = useCallback(() => { -// setIsPinDialogOpen(false); -// setPendingTransaction(null); -// setTransactionResolver(null); -// setIsProcessing(false); -// }, []); - -// return { -// isPinDialogOpen, -// isProcessing, -// requestTransactionPin, -// openPinDialog, -// closePinDialog, -// handlePinSubmit -// }; -// }; \ No newline at end of file diff --git a/components/hooks/useAddresses.tsx b/components/hooks/useAddresses.tsx deleted file mode 100644 index e1f6095..0000000 --- a/components/hooks/useAddresses.tsx +++ /dev/null @@ -1,32 +0,0 @@ -// import { useState, useEffect } from 'react'; -// import { useAuth } from '../context/AuthContext'; - -// export const useWalletAddresses = () => { -// const { getWalletAddresses, token } = useAuth(); -// const [addresses, setAddresses] = useState([]); -// const [loading, setLoading] = useState(false); -// const [error, setError] = useState(null); - -// const fetchAddresses = async () => { -// if (!token) return; - -// setLoading(true); -// setError(null); - -// try { -// const walletAddresses = await getWalletAddresses(); -// setAddresses(walletAddresses || []); -// } catch (err) { -// setError(err instanceof Error ? err.message : 'Failed to fetch addresses'); -// } finally { -// setLoading(false); -// } -// }; - -// useEffect(() => { -// fetchAddresses(); -// }, [token]); - - -// return { addresses, loading, error, refetch: fetchAddresses }; -// }; \ No newline at end of file diff --git a/components/hooks/useApiQuery.ts b/components/hooks/useApiQuery.ts new file mode 100644 index 0000000..3bd7411 --- /dev/null +++ b/components/hooks/useApiQuery.ts @@ -0,0 +1,132 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { apiClient } from '@/lib/api-client'; +import { StorageManager, getMemoryCache, setMemoryCache } from '@/lib/utils/storage-utils'; +import { useAuth } from '@/components/context/AuthContext'; + +interface UseApiQueryOptions { + ttl?: number; + backgroundRefresh?: boolean; + cacheKey: string; + requireAuth?: boolean; +} + +export function useApiQuery( + fetchFn: () => Promise, + options: UseApiQueryOptions +) { + const { + ttl = 5 * 60 * 1000, + backgroundRefresh = true, + cacheKey, + requireAuth = true, + } = options; + + const { token } = useAuth(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const mountedRef = useRef(true); + const fetchFnRef = useRef(fetchFn); + + useEffect(() => { + fetchFnRef.current = fetchFn; + }, [fetchFn]); + + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const fetchData = useCallback( + async (isBackgroundRefresh = false) => { + if (requireAuth && !token) { + if (!isBackgroundRefresh) { + setError('Authentication required'); + setIsLoading(false); + } + return; + } + + if (!isBackgroundRefresh) { + setIsLoading(true); + } + + try { + const result = await fetchFnRef.current(); + + // Update caches + setMemoryCache(cacheKey, result, ttl); + StorageManager.set(cacheKey, result); + + if (!mountedRef.current) return; + + setData(result); + setError(null); + } catch (err) { + if (!mountedRef.current) return; + + if (!isBackgroundRefresh) { + setError(err instanceof Error ? err.message : 'Failed to fetch data'); + } + console.error(`Error fetching ${cacheKey}:`, err); + } finally { + if (!isBackgroundRefresh) { + setIsLoading(false); + } + } + }, + [token, cacheKey, ttl, requireAuth] + ); + + const initializeData = useCallback(async () => { + // Try memory cache first + const memoryCached = getMemoryCache(cacheKey); + if (memoryCached) { + setData(memoryCached); + setIsLoading(false); + + if (backgroundRefresh) { + setTimeout(() => fetchData(true), 2000); + } + return; + } + + // Try storage cache + const storageCached = StorageManager.get(cacheKey); + if (storageCached) { + setData(storageCached); + setIsLoading(false); + + if (backgroundRefresh) { + setTimeout(() => fetchData(true), 2000); + } + return; + } + + // No cache - fetch fresh + await fetchData(false); + }, [cacheKey, fetchData, backgroundRefresh]); + + useEffect(() => { + initializeData(); + }, [initializeData]); + + useEffect(() => { + if (!backgroundRefresh) return; + + const interval = setInterval(() => { + if (!document.hidden) { + fetchData(true); + } + }, 30000); + + return () => clearInterval(interval); + }, [fetchData, backgroundRefresh]); + + const refetch = useCallback(async () => { + await fetchData(false); + }, [fetchData]); + + return { data, error, isLoading, refetch }; +} \ No newline at end of file diff --git a/components/hooks/useAuthQuery.ts b/components/hooks/useAuthQuery.ts new file mode 100644 index 0000000..658a5c2 --- /dev/null +++ b/components/hooks/useAuthQuery.ts @@ -0,0 +1,17 @@ +import { useApiQuery } from './useApiQuery'; +import { useAuth } from '@/components/context/AuthContext'; + +export function useAuthQuery( + fetchFn: () => Promise, + options: Omit[1], 'requireAuth'> +) { + const { token } = useAuth(); + + return useApiQuery(async () => { + if (!token) throw new Error('Authentication required'); + return fetchFn(); + }, { + ...options, + requireAuth: true, + }); +} \ No newline at end of file diff --git a/components/hooks/useBack.ts b/components/hooks/useBack.ts new file mode 100644 index 0000000..7a72ddb --- /dev/null +++ b/components/hooks/useBack.ts @@ -0,0 +1,22 @@ +"use client"; +import { useRouter } from "next/navigation"; + +/** + * Returns a function that navigates back to the previous page. + * Falls back to a default URL if there is no history. + * + * @param fallbackUrl - URL to navigate to if there is no previous page + */ +export const useBack = (fallbackUrl: string = "/") => { + const router = useRouter(); + + const goBack = () => { + if (window.history.length > 1) { + router.back(); + } else { + router.push(fallbackUrl); + } + }; + + return goBack; +}; diff --git a/components/hooks/useCachedQuery.ts b/components/hooks/useCachedQuery.ts deleted file mode 100644 index c80257f..0000000 --- a/components/hooks/useCachedQuery.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { apiClient } from '@/lib/api-client'; - -interface UseCachedQueryOptions { - ttl?: number; // Cache time-to-live in ms - backgroundRefresh?: boolean; -} - -export function useCachedQuery( - queryKey: string, - fetchFn: () => Promise, - options: UseCachedQueryOptions = {} -) { - const { ttl = 5 * 60 * 1000, backgroundRefresh = true } = options; - - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [isInitializing, setIsInitializing] = useState(true); - - const fetchData = useCallback(async (isBackgroundRefresh = false) => { - if (!isBackgroundRefresh) { - setIsInitializing(true); - } - - try { - // Try cache first (apiClient handles this internally) - const result = await fetchFn(); - setData(result); - setError(null); - } catch (err) { - if (!isBackgroundRefresh) { - setError(err instanceof Error ? err.message : 'Failed to fetch data'); - } - console.error(`Error fetching ${queryKey}:`, err); - } finally { - if (!isBackgroundRefresh) { - setIsInitializing(false); - } - } - }, [fetchFn, queryKey]); - - // Initial load - silent if cache exists - useEffect(() => { - const initializeData = async () => { - // Check if we have cached data - const cachedData = apiClient.getCachedData(queryKey); - - if (cachedData) { - setData(cachedData); - setIsInitializing(false); - - // Still refresh in background if data might be stale - if (backgroundRefresh) { - fetchData(true); - } - } else { - // No cache, need to fetch - await fetchData(false); - } - }; - - initializeData(); - }, [fetchData, queryKey, backgroundRefresh]); - - // Background refresh interval - useEffect(() => { - if (!backgroundRefresh) return; - - const interval = setInterval(() => { - if (!document.hidden) { - fetchData(true); - } - }, 30000); // Every 30 seconds - - return () => clearInterval(interval); - }, [fetchData, backgroundRefresh]); - - const refetch = useCallback(async () => { - await fetchData(false); - }, [fetchData]); - - return { - data, - error, - isInitializing, // Only true on very first load when no cache exists - refetch, - }; -} \ No newline at end of file diff --git a/components/hooks/useCommonOperations.ts b/components/hooks/useCommonOperations.ts new file mode 100644 index 0000000..768af8c --- /dev/null +++ b/components/hooks/useCommonOperations.ts @@ -0,0 +1,42 @@ +import { useCallback } from 'react'; +import { useAuth } from '@/components/context/AuthContext'; +import { normalizeChain, getTokenSymbol } from '@/lib/utils/token-utils'; + +export const useCommonOperations = () => { + const { token } = useAuth(); + + const requireAuth = useCallback(() => { + if (!token) throw new Error('Authentication required'); + return token; + }, [token]); + + const formatBalance = useCallback((balance: number): string => { + if (balance === 0) return "0.00"; + if (balance < 0.001) return "<0.001"; + return balance.toFixed(4); + }, []); + + const formatNGN = useCallback((amount: number): string => { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); + }, []); + + const shortenAddress = useCallback((address: string, chars = 8): string => { + if (!address || address.length <= chars * 2) return address; + return `${address.slice(0, chars)}...${address.slice(-chars)}`; + }, []); + + return { + requireAuth, + formatBalance, + formatNGN, + shortenAddress, + normalizeChain, + getTokenSymbol, + isAuthenticated: !!token, + }; +}; \ No newline at end of file diff --git a/components/hooks/useExchangeRate.tsx b/components/hooks/useExchangeRate.tsx index 169caec..3159a4a 100644 --- a/components/hooks/useExchangeRate.tsx +++ b/components/hooks/useExchangeRate.tsx @@ -9,7 +9,7 @@ type Rates = { BTC: number | null; SOL: number | null; DOT: number | null; - XML: number | null; + XLM: number | null; [key: string]: number | null; }; @@ -19,7 +19,7 @@ const MAX_RETRIES = 3; export default function useExchangeRates() { const [rates, setRates] = useState({ - USDT: 1, USDC: 1, STRK: 1, SOL: 1, ETH: 1, BTC: 1, DOT: 1, XML: 1, + USDT: 1, USDC: 1, STRK: 1, SOL: 1, ETH: 1, BTC: 1, DOT: 1, XLM: 1, }); const [lastUpdated, setLastUpdated] = useState(null); const [retryCount, setRetryCount] = useState(0); @@ -42,7 +42,6 @@ export default function useExchangeRates() { setIsLoading(true); try { - console.log('Fetching exchange rates...'); const response = await fetch(API_ENDPOINT, { headers: { @@ -56,7 +55,6 @@ export default function useExchangeRates() { } const data = await response.json(); - console.log(data) if (data.error) { throw new Error(data.error); } @@ -69,14 +67,13 @@ export default function useExchangeRates() { ETH: data.ethereum?.ngn ?? 1, SOL: data.solana?.ngn ?? 1, DOT: data.polkadot?.ngn ?? 1, - XML: data.stellar?.ngn ?? 1, + XLM: data.stellar?.ngn ?? 1, }; setRates(newRates); setLastUpdated(new Date()); setRetryCount(0); setError(null); - console.log('Rates fetched successfully'); } catch (err) { console.error("Rate fetch error:", err); const newRetryCount = retryCount + 1; @@ -94,7 +91,6 @@ export default function useExchangeRates() { fetchRates(); - // Set up interval - use fixed interval, not dynamic based on retryCount const intervalId = setInterval(() => { fetchRates(); }, REFETCH_INTERVAL); diff --git a/components/hooks/useGetBalance.tsx b/components/hooks/useGetBalance.tsx deleted file mode 100644 index 75047e7..0000000 --- a/components/hooks/useGetBalance.tsx +++ /dev/null @@ -1,144 +0,0 @@ -// import { useEffect, useState } from "react"; -// import { useAccount } from "@starknet-react/core"; -// import { Contract, RpcProvider } from "starknet"; -// import { VELO_ABI as BALANCE_ABI } from "./useSwiftContract"; -// import { TOKEN_ADDRESSES as tokenAddress } from "autoswap-sdk"; -// // token addresses -// const TOKEN_ADDRESSES = { -// STRK: tokenAddress.STRK, -// USDC: tokenAddress.USDC, -// USDT: tokenAddress.USDT, -// }; - -// // Token decimals for formatting -// const TOKEN_DECIMALS = { -// [TOKEN_ADDRESSES.STRK]: 18, -// [TOKEN_ADDRESSES.USDC]: 6, -// [TOKEN_ADDRESSES.USDT]: 6, -// }; - -// // Central contract address for get_balance -// const BALANCE_CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_CONTRACT_ADDRESS; - -// type Balances = { -// STRK: string; -// USDC: string; -// USDT: string; -// }; - -// type UseGetBalanceReturn = { -// balances: Balances; -// loading: boolean; -// error: string | null; -// refetch: () => void; -// }; - -// export default function useGetBalance(): UseGetBalanceReturn { -// const { address } = useAccount(); -// const [balances, setBalances] = useState({ -// STRK: "0", -// USDC: "0", -// USDT: "0", -// }); -// const [loading, setLoading] = useState(false); -// const [error, setError] = useState(null); - -// const provider = new RpcProvider({ -// nodeUrl: "https://starknet-sepolia.public.blastapi.io", -// }); - -// const fetchBalances = async () => { -// if (!address) { -// setBalances({ -// STRK: "0", -// USDC: "0", -// USDT: "0", -// }); -// setError("No wallet connected"); -// setLoading(false); -// return; -// } - -// setLoading(true); -// setError(null); - -// try { -// if (!BALANCE_CONTRACT_ADDRESS) { -// throw new Error("Balance contract address is not defined"); -// } - -// const contract = new Contract( -// BALANCE_ABI, -// BALANCE_CONTRACT_ADDRESS, -// provider -// ); - -// const balancePromises = Object.keys(TOKEN_ADDRESSES).map((token) => -// contract -// .call("get_balance", [ -// address, -// TOKEN_ADDRESSES[token as keyof typeof TOKEN_ADDRESSES], -// ]) -// .catch((err: any) => { -// console.error(`Error fetching balance for ${token}:`, err); -// throw new Error(`Failed to fetch ${token} balance: ${err.message}`); -// }) -// ); - -// const results = await Promise.allSettled(balancePromises); - -// const newBalances: Balances = { -// STRK: "0", -// USDC: "0", -// USDT: "0", -// }; - -// results.forEach((result, index) => { -// const token = Object.keys(TOKEN_ADDRESSES)[index] as keyof Balances; -// const decimals = TOKEN_DECIMALS[TOKEN_ADDRESSES[token]]; - -// if (result.status === "fulfilled") { -// const balanceValue = -// typeof result.value === "object" && "balance" in result.value -// ? result.value.balance -// : Array.isArray(result.value) -// ? result.value[0] -// : result.value; - -// newBalances[token] = (Number(balanceValue) / 10 ** decimals).toFixed( -// 2 -// ); -// } else { -// console.error(`Failed to fetch ${token} balance:`, result.reason); -// setError((prev) => -// prev ? `${prev}; ${result.reason.message}` : result.reason.message -// ); -// newBalances[token] = "0"; -// } -// }); - -// setBalances(newBalances); -// } catch (err: any) { -// console.error("Error fetching balances:", err); -// setError(`Failed to fetch balances: ${err.message || "Unknown error"}`); -// setBalances({ -// STRK: "0", -// USDC: "0", -// USDT: "0", -// }); -// } finally { -// setLoading(false); -// } -// }; - -// useEffect(() => { -// fetchBalances(); -// }, [address]); - -// return { -// balances, -// loading, -// error, -// refetch: fetchBalances, -// }; -// } diff --git a/components/hooks/useMerchantPayments.ts b/components/hooks/useMerchantPayments.ts index 4edee9b..4a19a51 100644 --- a/components/hooks/useMerchantPayments.ts +++ b/components/hooks/useMerchantPayments.ts @@ -1,244 +1,49 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useAuthQuery } from './useAuthQuery'; import { apiClient } from '@/lib/api-client'; -import { useAuth } from '@/components/context/AuthContext'; -import { - CreateMerchantPaymentRequest, - CreateMerchantPaymentResponse, - GetMerchantPaymentStatusResponse, - GetMerchantPaymentHistoryResponse, - PayMerchantInvoiceResponse -} from '@/types/authContext'; +import { useCommonOperations } from './useCommonOperations'; -interface MerchantPaymentStats { - total: number; - pending: number; - completed: number; - cancelled: number; - totalAmount: string; -} +export const useMerchantPayments = () => { + const { requireAuth } = useCommonOperations(); -interface UseMerchantPaymentsReturn { - // Data - paymentHistory: GetMerchantPaymentHistoryResponse['payments']; - stats: MerchantPaymentStats | null; - - // State - isLoading: boolean; // Only true on very first load with no cache - error: string | null; - - // Actions - createPayment: (request: CreateMerchantPaymentRequest) => Promise; - getPaymentStatus: (paymentId: string) => Promise; - payInvoice: (paymentId: string, fromAddress: string) => Promise; - fetchPaymentHistory: (params?: any) => Promise; - fetchStats: () => Promise; - refetchAll: () => Promise; -} + const { data: historyData, ...historyRest } = useAuthQuery( + () => apiClient.getMerchantPaymentHistory(), + { cacheKey: 'merchant-payment-history' } + ); -export const useMerchantPayments = (): UseMerchantPaymentsReturn => { - const { token } = useAuth(); - const [paymentHistory, setPaymentHistory] = useState([]); - const [stats, setStats] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [isInitialLoad, setIsInitialLoad] = useState(true); + const { data: statsData, ...statsRest } = useAuthQuery( + () => apiClient.getMerchantPaymentStats(), + { cacheKey: 'merchant-payment-stats' } + ); - // Silent background fetch for payment history - const silentFetchPaymentHistory = useCallback(async (params?: any) => { - if (!token) return; - - try { - const response = await apiClient.getMerchantPaymentHistory(params); - setPaymentHistory(response.payments || []); - } catch (err) { - console.error('Silent background payment history fetch failed:', err); - // Don't set error for background failures - } - }, [token]); - - // Silent background fetch for stats - const silentFetchStats = useCallback(async () => { - if (!token) return; - - try { - const response = await apiClient.getMerchantPaymentStats(); - setStats(response.stats); - } catch (err) { - console.error('Silent background stats fetch failed:', err); - // Don't set error for background failures - } - }, [token]); - - // Initial fetch with cache check for payment history - const initialFetchPaymentHistory = useCallback(async (params?: any) => { - if (!token) { - setError('Authentication required'); - return; - } - - try { - // Check if we have cached payment history - const cachedHistory = apiClient.getCachedData('merchant-payment-history'); - - // If we have cached data, show it immediately - if (cachedHistory) { - setPaymentHistory(cachedHistory); - - // Still fetch fresh data in background - silentFetchPaymentHistory(params); - return; - } - - // No cache - fetch fresh data - const response = await apiClient.getMerchantPaymentHistory(params); - setPaymentHistory(response.payments || []); - } catch (err) { - console.error('Error fetching payment history:', err); - setError(err instanceof Error ? err.message : 'Failed to fetch payment history'); - } - }, [token, silentFetchPaymentHistory]); - - // Initial fetch with cache check for stats - const initialFetchStats = useCallback(async () => { - if (!token) return; - - try { - // Check if we have cached stats - const cachedStats = apiClient.getCachedData('merchant-payment-stats'); - - // If we have cached data, show it immediately - if (cachedStats) { - setStats(cachedStats); - - // Still fetch fresh data in background - silentFetchStats(); - return; - } - - // No cache - fetch fresh data - const response = await apiClient.getMerchantPaymentStats(); - setStats(response.stats); - } catch (err) { - console.error('Error fetching merchant stats:', err); - // Don't set initial error for stats - it's less critical - } - }, [token, silentFetchStats]); - - // Manual fetch for payment history (shows loading if user triggers) - const fetchPaymentHistory = useCallback(async (params?: any) => { - if (!token) { - setError('Authentication required'); - return; - } - - try { - setIsLoading(true); - await silentFetchPaymentHistory(params); - } catch (err) { - console.error('Manual payment history fetch failed:', err); - setError(err instanceof Error ? err.message : 'Failed to fetch payment history'); - } finally { - setIsLoading(false); - } - }, [token, silentFetchPaymentHistory]); - - // Manual fetch for stats - const fetchStats = useCallback(async () => { - if (!token) return; - - try { - await silentFetchStats(); - } catch (err) { - console.error('Manual stats fetch failed:', err); - // Don't set error for manual stats fetch - } - }, [token, silentFetchStats]); - - // Action methods (unchanged - these are user-initiated) - const createPayment = useCallback(async (request: CreateMerchantPaymentRequest) => { - if (!token) throw new Error('Authentication required'); + const createPayment = async (request: any) => { + requireAuth(); return await apiClient.createMerchantPayment(request); - }, [token]); + }; - const getPaymentStatus = useCallback(async (paymentId: string) => { - if (!token) throw new Error('Authentication required'); + const getPaymentStatus = async (paymentId: string) => { + requireAuth(); return await apiClient.getMerchantPaymentStatus(paymentId); - }, [token]); + }; - const payInvoice = useCallback(async (paymentId: string, fromAddress: string) => { - if (!token) throw new Error('Authentication required'); + const payInvoice = async (paymentId: string, fromAddress: string) => { + requireAuth(); return await apiClient.payMerchantInvoice(paymentId, fromAddress); - }, [token]); - - // Manual refetch all (shows loading if user triggers) - const refetchAll = useCallback(async () => { - if (!token) return; - - try { - setIsLoading(true); - await Promise.all([ - silentFetchPaymentHistory(), - silentFetchStats() - ]); - } catch (err) { - console.error('Manual refetch all failed:', err); - setError(err instanceof Error ? err.message : 'Failed to refetch merchant data'); - } finally { - setIsLoading(false); - } - }, [token, silentFetchPaymentHistory, silentFetchStats]); - - // Initial load - check cache first - useEffect(() => { - if (token) { - const initializeData = async () => { - try { - setIsLoading(true); - await Promise.all([ - initialFetchPaymentHistory(), - initialFetchStats() - ]); - } catch (err) { - console.error('Initial merchant data load failed:', err); - setError(err instanceof Error ? err.message : 'Failed to load merchant data'); - } finally { - setIsLoading(false); - setIsInitialLoad(false); - } - }; - - initializeData(); - } - }, [token, initialFetchPaymentHistory, initialFetchStats]); - - // Setup silent background refresh (every 5 minutes - merchant data changes less frequently) - useEffect(() => { - if (!token || isInitialLoad) return; - - const interval = setInterval(() => { - // Only refresh if tab is visible - if (!document.hidden) { - silentFetchPaymentHistory(); - silentFetchStats(); - } - }, 5 * 60 * 1000); // 5 minutes - - return () => clearInterval(interval); - }, [token, silentFetchPaymentHistory, silentFetchStats, isInitialLoad]); + }; - // Only show loading on very first load when no cache exists - const shouldShowLoading = isLoading && isInitialLoad; + const refetchAll = async () => { + await Promise.all([historyRest.refetch(), statsRest.refetch()]); + }; return { - paymentHistory, - stats, - isLoading: shouldShowLoading, - error, + paymentHistory: historyData?.payments || [], + stats: statsData?.stats || null, + isLoading: historyRest.isLoading || statsRest.isLoading, + error: historyRest.error || statsRest.error, createPayment, getPaymentStatus, payInvoice, - fetchPaymentHistory, - fetchStats, + fetchPaymentHistory: historyRest.refetch, + fetchStats: statsRest.refetch, refetchAll, }; }; \ No newline at end of file diff --git a/components/hooks/useNotifications.ts b/components/hooks/useNotifications.ts index de4346a..cea2bfd 100644 --- a/components/hooks/useNotifications.ts +++ b/components/hooks/useNotifications.ts @@ -3,7 +3,7 @@ import { apiClient } from "@/lib/api-client"; import { useAuth } from "@/components/context/AuthContext"; import { useToastNotifications } from "./useToastNotifications "; import { BackendNotification, FrontendNotification } from "@/types/index"; -import { useSilentQuery } from "./useSilentQuery"; +import { useApiQuery } from "./useApiQuery"; // Type guard to check if a string is a valid category const isValidCategory = ( @@ -75,7 +75,7 @@ export const useNotifications = () => { data: notificationsData, error: notificationsError, refetch: refetchNotifications - } = useSilentQuery( + } = useApiQuery( () => apiClient.getNotifications({ page: 1, limit: 1000 }), { cacheKey: 'notifications-all', @@ -88,7 +88,7 @@ export const useNotifications = () => { const { data: unreadCountData, refetch: refetchUnreadCount - } = useSilentQuery( + } = useApiQuery( () => apiClient.getUnreadCount(), { cacheKey: 'notifications-unread-count', diff --git a/components/hooks/usePin.tsx b/components/hooks/usePin.tsx index f29c880..c3b0a39 100644 --- a/components/hooks/usePin.tsx +++ b/components/hooks/usePin.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo, useState } from 'react'; import { apiClient } from '@/lib/api-client'; import { useAuth } from '@/components/context/AuthContext'; -import { useSilentQuery } from './useSilentQuery'; +import { useApiQuery } from './useApiQuery'; import { toast } from 'sonner'; interface PinResponse { @@ -40,7 +40,7 @@ export const usePin = (): UsePinReturn => { }); // Check PIN status from user profile - const { refetch: refetchProfile } = useSilentQuery( + const { refetch: refetchProfile } = useApiQuery( async (): Promise<{ hasTransactionPin?: boolean; [key: string]: any }> => { try { const profile = await apiClient.getUserProfile(); diff --git a/components/hooks/usePurchaseConfig.ts b/components/hooks/usePurchaseConfig.ts new file mode 100644 index 0000000..10fc12b --- /dev/null +++ b/components/hooks/usePurchaseConfig.ts @@ -0,0 +1,214 @@ +import { useCallback, useMemo } from "react"; +import { PhoneCall, Wifi, Zap } from "lucide-react"; + +export interface PurchaseConfig { + title: string; + icon: any; // React component + step1Title: string; + step1Description: string; + customerLabel: string; + placeholder: string; + showAmountGrid: boolean; + showVariations: boolean; + showMeterType: boolean; + showVerifyButton: boolean; + receiptFields: string[]; +} + +export function usePurchaseConfig(type: "airtime" | "data" | "electricity") { + const config = useMemo((): PurchaseConfig => { + const baseConfigs = { + airtime: { + title: "Buy Airtime", + icon: PhoneCall, + step1Title: "Select network", + step1Description: "Choose your network provider", + customerLabel: "Phone Number", + placeholder: "8012345678", + showAmountGrid: true, + showVariations: false, + showMeterType: false, + showVerifyButton: false, + receiptFields: ["Network", "Phone Number", "Amount", "Transaction ID"], + }, + data: { + title: "Purchase Data", + icon: Wifi, + step1Title: "Select network", + step1Description: "Choose your network provider", + customerLabel: "Phone Number", + placeholder: "8012345678", + showAmountGrid: false, + showVariations: true, + showMeterType: false, + showVerifyButton: false, + receiptFields: ["Network", "Phone Number", "Data Plan", "Transaction ID"], + }, + electricity: { + title: "Electricity Bill", + icon: Zap, + step1Title: "Select provider", + step1Description: "Choose your electricity provider", + customerLabel: "Meter Number", + placeholder: "Enter meter number", + showAmountGrid: true, + showVariations: false, + showMeterType: true, + showVerifyButton: true, + receiptFields: ["Provider", "Meter Number", "Amount", "Token", "Transaction ID"], + }, + }; + + return baseConfigs[type]; + }, [type]); + + // Helper function to get preset amounts based on type + const getPresetAmounts = useMemo(() => { + const typePresets = { + airtime: [100, 200, 500, 1000, 2000, 5000], + data: [100, 200, 500, 1000, 2000, 5000], // Not used for data, but available + electricity: [1000, 2000, 5000, 10000, 20000, 50000], + }; + return typePresets[type]; + }, [type]); + + // Get validation rules for customer input + const getValidationRules = useMemo(() => { + const rules = { + airtime: { + pattern: /^[0-9]{10}$/, + errorMessage: "Please enter a valid 10-digit phone number", + }, + data: { + pattern: /^[0-9]{10}$/, + errorMessage: "Please enter a valid 10-digit phone number", + }, + electricity: { + pattern: /^[0-9]{6,20}$/, + errorMessage: "Please enter a valid meter number", + }, + }; + return rules[type]; + }, [type]); + + // Get required fields for step 1 + const getRequiredFields = useMemo(() => { + const required = { + airtime: ["service_id", "amount", "customer_id"], + data: ["service_id", "dataplan", "customer_id"], + electricity: ["service_id", "amount", "customer_id", "meterType"], + }; + return required[type]; + }, [type]); + + // Get crypto token recommendations based on type + const getRecommendedTokens = useMemo(() => { + const recommendations = { + airtime: ["ethereum", "bitcoin", "usdt-erc20"], + data: ["ethereum", "solana", "usdt-erc20"], + electricity: ["ethereum", "starknet", "usdt-erc20"], + }; + return recommendations[type]; + }, [type]); + + // Format customer ID for display + const formatCustomerId = useCallback((customerId: string): string => { + if (type === "electricity") { + return customerId; + } + // Add +234 prefix for phone numbers + return `+234 ${customerId.slice(0, 3)} ${customerId.slice(3, 6)} ${customerId.slice(6)}`; + }, [type]); + + // Get transaction description + const getTransactionDescription = useCallback( + (providerName: string, amount: string, customerId: string): string => { + const formattedCustomerId = formatCustomerId(customerId); + + const descriptions = { + airtime: `${providerName} airtime recharge of ₦${amount} for ${formattedCustomerId}`, + data: `${providerName} data purchase for ${formattedCustomerId}`, + electricity: `${providerName} electricity bill payment of ₦${amount} for meter ${formattedCustomerId}`, + }; + + return descriptions[type]; + }, + [type, formatCustomerId] + ); + + // Get provider image path + const getProviderImagePath = useCallback((providerCode: string): string => { + const basePath = "/img/providers"; + const images = { + airtime: `${basePath}/telco/${providerCode.toLowerCase()}.png`, + data: `${basePath}/telco/${providerCode.toLowerCase()}.png`, + electricity: `${basePath}/power/${providerCode.toLowerCase()}.png`, + }; + return images[type]; + }, [type]); + + // Check if amount is within provider limits + const isAmountValid = useCallback( + (amount: number, provider?: { minAmount?: number; maxAmount?: number }): { + valid: boolean; + error?: string; + } => { + if (!amount || amount <= 0) { + return { valid: false, error: "Amount must be greater than 0" }; + } + + if (provider?.minAmount && amount < provider.minAmount) { + return { + valid: false, + error: `Minimum amount is ₦${provider.minAmount.toLocaleString()}`, + }; + } + + if (provider?.maxAmount && amount > provider.maxAmount) { + return { + valid: false, + error: `Maximum amount is ₦${provider.maxAmount.toLocaleString()}`, + }; + } + + return { valid: true }; + }, + [] + ); + + // Get step titles for the entire flow + const getStepTitles = useMemo(() => { + const steps = { + 1: config.step1Title, + 2: "Select Payment Method", + 3: "Review & Confirm", + 4: "Transaction Result", + }; + return steps; + }, [config.step1Title]); + + // Get progress percentage + const getProgressPercentage = useCallback((step: number): number => { + const percentages: Record = { + 1: 25, + 2: 50, + 3: 75, + 4: 100, + }; + return percentages[step] || 0; + }, []); + + return { + config, + getPresetAmounts, + getValidationRules, + getRequiredFields, + getRecommendedTokens, + formatCustomerId, + getTransactionDescription, + getProviderImagePath, + isAmountValid, + getStepTitles, + getProgressPercentage, + }; +} \ No newline at end of file diff --git a/components/hooks/usePurchaseFlow.ts b/components/hooks/usePurchaseFlow.ts new file mode 100644 index 0000000..2c2efe0 --- /dev/null +++ b/components/hooks/usePurchaseFlow.ts @@ -0,0 +1,751 @@ +// /components/purchase/hooks/usePurchaseFlow.ts +import { useState, useEffect, useCallback, useMemo } from "react"; +import { useAuth } from "@/components/context/AuthContext"; +import { useWalletData } from "@/components/hooks"; +import useExchangeRates from "@/components/hooks/useExchangeRate"; +import { normalizeStarknetAddress } from "@/components/lib/utils"; +import { apiClient } from "@/lib/api-client"; +import { validatePhoneNumber } from "@/lib/utils"; +import { toast } from "sonner"; + +export interface PurchaseFormData { + service_id: string; + amount: string; + customer_id: string; + meterType: "prepaid" | "postpaid"; + dataplan: any | null; + expectedAmount: any | null; + transactionData: any | null; + phoneNo: string; +} + +interface Provider { + serviceID: string; + name: string; + image: string; + code?: string; + minAmount?: number; + maxAmount?: number; +} + +interface ExpectedAmount { + cryptoAmount: number; + cryptoCurrency: string; + chain: string; +} + +const presetAmounts = [100, 200, 500, 1000, 2000, 5000]; + +const getConfig = (type: "airtime" | "data" | "electricity") => { + const config = { + airtime: { + title: "Buy Airtime", + step1Title: "Select network", + step1Description: "Choose your network provider", + customerLabel: "Phone Number", + placeholder: "8012345678", + showAmountGrid: true, + showVariations: false, + showMeterType: false, + showVerifyButton: false, + }, + data: { + title: "Purchase Data", + step1Title: "Select network", + step1Description: "Choose your network provider", + customerLabel: "Phone Number", + placeholder: "8012345678", + showAmountGrid: false, + showVariations: true, + showMeterType: false, + showVerifyButton: false, + }, + electricity: { + title: "Electricity Bill", + step1Title: "Select provider", + step1Description: "Choose your electricity provider", + customerLabel: "Meter Number", + placeholder: "Enter meter number", + showAmountGrid: true, + showVariations: false, + showMeterType: true, + showVerifyButton: true, + }, + }; + return config[type]; +}; + +export function usePurchaseFlow({ type }: { type: "airtime" | "data" | "electricity" }) { + const [step, setStep] = useState(1); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(null); + const [showPinDialog, setShowPinDialog] = useState(false); + const [isSending, setIsSending] = useState(false); + const [selectedToken, setSelectedToken] = useState("ethereum"); + const [toAddress, setToAddress] = useState(""); + const [txHash, setTxHash] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [merchantFallback, setMerchantFallback] = useState(false); + const [verifyingMeter, setVerifyingMeter] = useState(false); + const [meterVerified, setMeterVerified] = useState(false); + const [meterVerificationMessage, setMeterVerificationMessage] = useState(""); + const [transactionData, setTransactionData] = useState(null); + + const [formData, setFormData] = useState({ + service_id: "", + amount: "", + customer_id: "", + meterType: "prepaid", + dataplan: null, + expectedAmount: null, + transactionData: null, + phoneNo: "", + }); + + const [providers, setProviders] = useState([]); + const [dataPlans, setDataPlans] = useState([]); + const [electricityCompanies, setElectricityCompanies] = useState([]); + const [meterTypes, setMeterTypes] = useState([]); + + const { sendTransaction } = useAuth(); + const { rates } = useExchangeRates(); + const { addresses, balances } = useWalletData(); + + const config = useMemo(() => getConfig(type), [type]); + + // Get merchant wallet address for selected token + const getToAddress = useCallback((chain: string) => { + if (typeof window !== "undefined") { + const runtime: any = (window as any).__VELO_MERCHANT_WALLETS; + if (runtime && typeof runtime === "object" && runtime[chain]) { + return String(runtime[chain]); + } + } + + const walletMap: { [key: string]: string | undefined } = { + ethereum: process.env.NEXT_PUBLIC_ETH_WALLET, + bitcoin: process.env.NEXT_PUBLIC_BTC_WALLET, + solana: process.env.NEXT_PUBLIC_SOL_WALLET, + stellar: process.env.NEXT_PUBLIC_XLM_WALLET, + polkadot: process.env.NEXT_PUBLIC_DOT_WALLET, + starknet: process.env.NEXT_PUBLIC_STRK_WALLET, + "usdt-erc20": process.env.NEXT_PUBLIC_USDT_WALLET, + }; + return walletMap[chain] || ""; + }, []); + + // Fetch providers based on type + const fetchProviders = useCallback(async () => { + setLoading(true); + try { + if (type === "electricity") { + const { companies } = await apiClient.getElectricitySupportedOptions(); + setElectricityCompanies(companies); + + const mappedProviders: Provider[] = companies.map((company) => ({ + serviceID: company.value, + name: company.label, + image: `/img/${company.value}.png`, + code: company.code, + minAmount: company.minAmount, + maxAmount: company.maxAmount, + })); + setProviders(mappedProviders); + } else { + const networks = + type === "data" + ? await apiClient.getDataSupportedNetworks() + : await apiClient.getAirtimeSupportedNetworks(); + + const mappedProviders: Provider[] = networks.map((network) => ({ + serviceID: network.value, + name: network.label, + image: `/img/${network.value.toLowerCase()}.png`, + })); + setProviders(mappedProviders); + } + } catch (error) { + console.error("Failed to fetch providers:", error); + setErrorMessage("Failed to load providers. Please try again."); + } finally { + setLoading(false); + } + }, [type]); + + // Fetch data plans for selected network + const fetchDataPlans = useCallback(async (network: string) => { + setLoading(true); + try { + const plans = await apiClient.getDataPlans(network, false); + setDataPlans(plans); + } catch (error) { + console.error("Failed to fetch data plans:", error); + setErrorMessage("Failed to load data plans. Please try again."); + } finally { + setLoading(false); + } + }, []); + + // Fetch meter types for electricity + const fetchMeterTypes = useCallback(async () => { + setLoading(true); + try { + const { meterTypes: types } = await apiClient.getElectricitySupportedOptions(); + setMeterTypes(types); + } catch (error) { + console.error("Failed to fetch meter types:", error); + } finally { + setLoading(false); + } + }, []); + + // Verify meter number + const handleVerifyMeter = useCallback(async () => { + if (!formData.customer_id || !formData.service_id) { + toast.error("Please enter meter number and select provider"); + return; + } + + setVerifyingMeter(true); + setMeterVerificationMessage(""); + + try { + const result = await apiClient.verifyElectricityMeter( + formData.service_id, + formData.customer_id + ); + + if (result.success && result.data && result.data.valid) { + setMeterVerified(true); + const customerInfo = result.data.customerName + ? `✓ ${result.data.customerName} ` + : `✓ Meter verified: ${result.data.company}`; + toast.success(customerInfo); + } else { + setMeterVerified(false); + toast.error(result.message || "✗ Invalid meter number"); + } + } catch (error: any) { + setMeterVerified(false); + toast.error( + error.message || "Verification failed. Please try again." + ); + } finally { + setVerifyingMeter(false); + } + }, [formData.customer_id, formData.service_id]); + + // Get expected crypto amount + const fetchExpectedAmount = useCallback(async () => { + try { + let expectedAmount: ExpectedAmount; + + if (type === "airtime") { + const amount = parseFloat(formData.amount); + expectedAmount = await apiClient.getAirtimeExpectedAmount( + amount, + selectedToken + ); + } else if (type === "electricity") { + const amount = parseFloat(formData.amount); + expectedAmount = await apiClient.getElectricityExpectedAmount( + amount, + selectedToken + ); + } else if (type === "data" && formData.dataplan) { + expectedAmount = await apiClient.getDataExpectedAmount( + formData.dataplan.dataplanId, + formData.service_id, + selectedToken + ); + } else { + throw new Error("Invalid purchase configuration"); + } + + setFormData((prev) => ({ ...prev, expectedAmount })); + } catch (error: any) { + console.error("Failed to fetch expected amount:", error); + setErrorMessage(error.message || "Failed to calculate crypto amount"); + } + }, [type, formData.amount, formData.dataplan, formData.service_id, selectedToken]); + + // Current wallet balance for selected token + const currentWalletBalance = useMemo(() => { + const balanceInfo = balances.find( + (b) => (b.chain || "").toLowerCase() === selectedToken.toLowerCase() + ); + return parseFloat(balanceInfo?.balance || "0"); + }, [balances, selectedToken]); + + // Current wallet address for selected token + const currentWalletAddress = useMemo(() => { + if (!addresses) return ""; + const addressInfo = addresses.find( + (addr) => (addr.chain || "").toLowerCase() === selectedToken.toLowerCase() + ); + return addressInfo?.address || ""; + }, [addresses, selectedToken]); + + // Current network for selected token + const currentNetwork = useMemo(() => { + if (!addresses) return "testnet"; + const addressInfo = addresses.find( + (addr) => (addr.chain || "").toLowerCase() === selectedToken.toLowerCase() + ); + return addressInfo?.network || "testnet"; + }, [addresses, selectedToken]); + + // Required crypto amount + const requiredCryptoAmount = useMemo(() => { + if (!formData.expectedAmount?.cryptoAmount) return 0; + const amount = formData.expectedAmount.cryptoAmount; + return Math.ceil(amount * 1e7) / 1e7; + }, [formData.expectedAmount]); + + // Estimate crypto from exchange rates fallback + const estimateCryptoFromRates = useCallback( + (ngnAmount: number, token: string) => { + try { + const r: any = rates || {}; + const rateFor = r[token]; + if (!rateFor || !rateFor.ngn) return 0; + const crypto = ngnAmount / parseFloat(String(rateFor.ngn)); + return Math.ceil(crypto * 1e7) / 1e7; + } catch (e) { + return 0; + } + }, + [rates] + ); + + // Find token with sufficient balance + const findTokenWithSufficientBalance = useCallback( + (ngnAmount: number) => { + const rateMap: any = rates || {}; + const candidates = (balances || []) + .map((b: any) => { + const token = b.chain; + const bal = parseFloat(b.balance || "0"); + const rate = rateMap[token]?.ngn ? parseFloat(rateMap[token].ngn) : 0; + return { token, bal, rate, ngnValue: bal * (rate || 0) }; + }) + .sort((a: any, b: any) => b.ngnValue - a.ngnValue); + + const curr = candidates.find((c: any) => c.token === selectedToken); + if (curr && curr.ngnValue >= ngnAmount) return curr.token; + + const found = candidates.find((c: any) => c.ngnValue >= ngnAmount); + return found ? found.token : null; + }, + [balances, rates, selectedToken] + ); + + // Validation error + const validationError = useMemo(() => { + const merchantAddress = getToAddress(selectedToken.toLowerCase()) || ""; + + if (!currentWalletAddress) { + return "No wallet found for this currency. Add a wallet or select another currency."; + } + + if (!merchantAddress && process.env.NODE_ENV === "production") { + return `Merchant wallet for ${selectedToken.toUpperCase()} is not configured.`; + } + + if (!toAddress.trim()) { + return "Recipient address is required"; + } + + if (!formData.amount || parseFloat(formData.amount) <= 0) { + return "Amount must be greater than 0"; + } + + if (requiredCryptoAmount > currentWalletBalance) { + return "Insufficient balance"; + } + + if (type === "electricity" && config.showVerifyButton && !meterVerified) { + return "Please verify meter number first"; + } + + return null; + }, [ + currentWalletAddress, + selectedToken, + toAddress, + formData.amount, + requiredCryptoAmount, + currentWalletBalance, + type, + config.showVerifyButton, + meterVerified, + getToAddress, + ]); + + // Handle token selection + const handleTokenSelect = useCallback((chain: string) => { + setSelectedToken(chain); + const addr = getToAddress(chain.toLowerCase()); + if (addr) { + setToAddress(addr); + } + }, [getToAddress]); + + // Handle send transaction with PIN + const handleSendWithPin = useCallback(async (pin: string) => { + console.log("phone number", validatePhoneNumber(formData.customer_id),) + + setErrorMessage(""); + + if (validationError) { + setErrorMessage(validationError); + setShowPinDialog(false); + return; + } + + setIsSending(true); + + try { + let normalizedToAddress = toAddress.trim(); + + if (selectedToken === "starknet") { + try { + normalizedToAddress = normalizeStarknetAddress(toAddress, "starknet"); + } catch (error) { + throw new Error( + error instanceof Error + ? `Invalid Starknet address: ${error.message}` + : "Invalid Starknet address format" + ); + } + } + + // Dev mode simulation for self-transfers + // if (normalizedToAddress === currentWalletAddress) { + // if (process.env.NODE_ENV === "production") { + // throw new Error("Recipient address cannot be the same as the sender."); + // } + + // } + + const transactionResponse = await sendTransaction({ + chain: selectedToken, + network: currentNetwork, + toAddress: normalizedToAddress, + amount: requiredCryptoAmount.toString(), + fromAddress: currentWalletAddress, + transactionPin: pin, + }); + + setTxHash(transactionResponse.txHash); + setShowPinDialog(false); + + await handleSubmitPurchase(transactionResponse.txHash); + setStep(4); + } catch (error: any) { + console.error("Transaction error:", error); + let errMsg = "Failed to send transaction. Please try again."; + + if (error.message) { + errMsg = error.message; + } else if (typeof error === "string") { + errMsg = error; + } + + setErrorMessage(errMsg); + setShowPinDialog(false); + } finally { + setIsSending(false); + } + }, [ + validationError, + toAddress, + selectedToken, + currentWalletAddress, + currentNetwork, + requiredCryptoAmount, + sendTransaction, + ]); + + // Submit purchase to backend + const handleSubmitPurchase = useCallback(async (transactionHash: string) => { + setLoading(true); + + try { + let response; + + if (type === "airtime") { + const provider = providers.find((p) => p.serviceID === formData.service_id) || null; + const metadata = { + provider, + expectedAmount: formData.expectedAmount || null, + selectedToken, + fromAddress: currentWalletAddress, + merchantAddress: getToAddress(selectedToken), + purchaseType: "AirtimePurchase", + }; + + response = await apiClient.purchaseAirtime({ + type: "airtime", + amount: parseFloat(formData.amount), + chain: selectedToken, + phoneNumber: validatePhoneNumber(formData.customer_id), + mobileNetwork: formData.service_id, + transactionHash, + } as any); + } else if (type === "data" && formData.dataplan) { + const provider = providers.find((p) => p.serviceID === formData.service_id) || null; + // const metadata = { + // provider, + // dataplan: formData.dataplan, + // expectedAmount: formData.expectedAmount || null, + // selectedToken, + // fromAddress: currentWalletAddress, + // merchantAddress: getToAddress(selectedToken), + // purchaseType: "DataPurchase", + // }; + + response = await apiClient.purchaseData({ + type: "data", + dataplanId: formData.dataplan.dataplanId, + amount: parseFloat(formData.dataplan.amount.replace(/[N₦,]/g, "")), + chain: selectedToken, + phoneNumber: validatePhoneNumber(formData.customer_id), + mobileNetwork: formData.service_id, + transactionHash, + } as any); + } else if (type === "electricity") { + const companyInfo = electricityCompanies.find((c) => c.value === formData.service_id) || null; + const metadata = { + company: companyInfo, + expectedAmount: formData.expectedAmount || null, + selectedToken, + fromAddress: currentWalletAddress, + merchantAddress: getToAddress(selectedToken), + purchaseType: "ElectricityPurchase", + }; + + response = await apiClient.purchaseElectricity({ + type: "electricity", + amount: parseFloat(formData.amount), + chain: selectedToken, + company: formData.service_id, + meterType: formData.meterType, + meterNumber: formData.customer_id, + phoneNumber: validatePhoneNumber(formData.phoneNo), + transactionHash, + } as any); + } else { + throw new Error("Invalid purchase type"); + } + + if (response.success) { + setSuccess(true); + setTransactionData(response.data); + } else { + setSuccess(false); + setErrorMessage(response.message || "Purchase failed"); + } + } catch (error: any) { + console.error("Purchase error:", error); + setSuccess(false); + setErrorMessage(error.message || "Failed to complete purchase"); + } finally { + setLoading(false); + } + }, [ + type, + providers, + electricityCompanies, + formData, + selectedToken, + currentWalletAddress, + getToAddress, + ]); + + // Step 1 validation + const isStep1Valid = useCallback(() => { + if (!formData.service_id) return false; + if (!formData.customer_id) return false; + + if (type === "data" && !formData.dataplan) return false; + if ((type === "airtime" || type === "electricity") && !formData.amount) + return false; + if ( + type === "electricity" && + config.showVerifyButton && + !meterVerified && + !formData.phoneNo + ) + return false; + + return true; + }, [formData, type, meterVerified, config]); + + // Handlers + const handleBack = useCallback(() => { + if (step === 1) { + window.history.back(); + } else { + setStep((prev) => prev - 1); + setErrorMessage(""); + } + }, [step]); + + const handleNext = useCallback(() => { + setErrorMessage(""); + setStep((prev) => prev + 1); + }, []); + + const handleConfirm = useCallback(() => { + if (validationError) { + setErrorMessage(validationError); + return; + } + setShowPinDialog(true); + }, [validationError]); + + const resetForm = useCallback(() => { + setFormData({ + service_id: "", + amount: "", + customer_id: "", + meterType: "prepaid", + dataplan: null, + expectedAmount: null, + transactionData: null, + phoneNo: "", + }); + setToAddress(""); + setTxHash(""); + setErrorMessage(""); + setMeterVerified(false); + setMeterVerificationMessage(""); + setSuccess(null); + setStep(1); + }, []); + + // Effects + useEffect(() => { + fetchProviders(); + if (type === "electricity") { + fetchMeterTypes(); + } + }, [type, fetchProviders, fetchMeterTypes]); + + useEffect(() => { + if (type === "data" && formData.service_id) { + fetchDataPlans(formData.service_id); + } + }, [type, formData.service_id, fetchDataPlans]); + + useEffect(() => { + if (step === 2 && selectedToken) { + fetchExpectedAmount(); + } + }, [step, selectedToken, fetchExpectedAmount]); + + useEffect(() => { + // Auto-fill recipient address + try { + const addr = getToAddress(selectedToken.toLowerCase()); + if (addr && !toAddress) { + setToAddress(addr); + } + } catch (e) { + // ignore + } + }, [selectedToken, addresses, toAddress, getToAddress]); + + useEffect(() => { + // Dev-only fallback + if (process.env.NODE_ENV === "production") return; + try { + const addr = getToAddress(selectedToken.toLowerCase()); + if (!addr && !toAddress && currentWalletAddress) { + setToAddress(currentWalletAddress); + setMerchantFallback(true); + } else { + setMerchantFallback(false); + } + } catch (e) { + // ignore + } + }, [selectedToken, addresses, currentWalletAddress, toAddress, getToAddress]); + + useEffect(() => { + // Auto-select token with sufficient balance + if (step !== 2) return; + const ngnAmount = parseFloat(formData.amount || "0"); + if (!ngnAmount || ngnAmount <= 0) return; + + const expected = formData.expectedAmount; + if (expected && expected.cryptoAmount && expected.cryptoCurrency) { + const expectedToken = expected.chain || expected.cryptoCurrency?.toLowerCase(); + if (expectedToken && expectedToken !== selectedToken) { + setSelectedToken(expectedToken); + setToAddress(getToAddress(expectedToken)); + } + return; + } + + const candidate = findTokenWithSufficientBalance(ngnAmount); + if (candidate && candidate !== selectedToken) { + setSelectedToken(candidate); + setToAddress(getToAddress(candidate)); + } + }, [step, formData.amount, formData.expectedAmount, findTokenWithSufficientBalance, selectedToken, getToAddress]); + + return { + // State + step, + loading, + success, + showPinDialog, + isSending, + selectedToken, + toAddress, + errorMessage, + verifyingMeter, + meterVerified, + meterVerificationMessage, + formData, + providers, + dataPlans, + electricityCompanies, + meterTypes, + transactionData, + txHash, + merchantFallback, + + // Setters + setStep, + setFormData, + setShowPinDialog, + setErrorMessage, + + // Handlers + handleBack, + handleNext, + handleConfirm, + handleTokenSelect, + handleSendWithPin, + handleVerifyMeter, + resetForm, + + // Validation + isStep1Valid, + validationError, + + // Utilities + config, + presetAmounts, + requiredCryptoAmount, + currentWalletBalance, + currentWalletAddress, + currentNetwork, + + // Data + getToAddress, + }; +} \ No newline at end of file diff --git a/components/hooks/useSilentQuery.ts b/components/hooks/useSilentQuery.ts deleted file mode 100644 index 5c4ddde..0000000 --- a/components/hooks/useSilentQuery.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from "react"; - -interface UseSilentQueryOptions { - ttl?: number; - backgroundRefresh?: boolean; - cacheKey: string; -} - -// Simple in-memory cache (per session) -const sessionCache = new Map< - string, - { data: any; timestamp: number; ttl: number } ->(); - -export function useSilentQuery( - fetchFn: () => Promise, - options: UseSilentQueryOptions -) { - const { ttl = 5 * 60 * 1000, backgroundRefresh = true, cacheKey } = options; - - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const mountedRef = useRef(true); - // Keep a ref to the latest fetchFn so callers can pass inline functions - // without causing this hook to re-create callbacks on every render. - const fetchFnRef = useRef(fetchFn); - useEffect(() => { - fetchFnRef.current = fetchFn; - }, [fetchFn]); - - // Cleanup flag to prevent state update on unmounted component - useEffect(() => { - return () => { - mountedRef.current = false; - }; - }, []); - - /** 🔹 Get cached data safely */ - const getCachedData = useCallback((): T | null => { - const cached = sessionCache.get(cacheKey); - if (!cached) return null; - - const isExpired = Date.now() - cached.timestamp > cached.ttl; - if (isExpired) { - sessionCache.delete(cacheKey); - return null; - } - - return cached.data; - }, [cacheKey]); - - /** 🔹 Store data in cache */ - const setCachedData = useCallback( - (data: T) => { - sessionCache.set(cacheKey, { - data, - timestamp: Date.now(), - ttl, - }); - }, - [cacheKey, ttl] - ); - - /** 🔹 Fetch fresh data */ - const fetchData = useCallback( - async (isBackgroundRefresh = false) => { - try { - // call the latest fetchFn from ref to avoid recreating this callback - const result = await fetchFnRef.current(); - setCachedData(result); - - // Avoid state updates if unmounted - if (!mountedRef.current) return; - - setData(result); - setError(null); - } catch (err) { - if (!mountedRef.current) return; - if (!isBackgroundRefresh) { - setError(err instanceof Error ? err.message : "Failed to fetch data"); - } - } - }, - [setCachedData] - ); - - /** 🔹 Initialize once on mount */ - useEffect(() => { - const initializeData = async () => { - const cachedData = getCachedData(); - // If the app is initializing auth, defer fetches until auth is ready. - const isInitializing = typeof window !== "undefined" && (window as any).__VELO_AUTH_INITIALIZING; - - if (cachedData) { - setData(cachedData); - - if (backgroundRefresh) { - // If auth is initializing, subscribe to the auth-ready event and - // perform background refresh once initialization completes. Otherwise - // schedule a silent refresh after 2s. - if (isInitializing && typeof window !== "undefined") { - const onReady = () => { - try { - fetchData(true); - } catch (e) { - /* ignore */ - } - window.removeEventListener("velo:authReady", onReady); - }; - window.addEventListener("velo:authReady", onReady); - } else { - setTimeout(() => { - fetchData(true); - }, 2000); - } - } - } else { - // No cached data: start background fetch only if auth init is finished. - if (isInitializing) { - // wait for auth to finish, then fetch - if (typeof window !== "undefined") { - const onReady = () => { - try { - void fetchData(true); - } catch (e) { - /* ignore */ - } - window.removeEventListener("velo:authReady", onReady); - }; - window.addEventListener("velo:authReady", onReady); - } - } else { - // Start background fetch but don't await it so the UI isn't blocked - // on first mount when no cache exists. Components should provide a - // sessionStorage fallback or loading UI for the very first render. - void fetchData(true); - } - } - }; - - initializeData(); - // ✅ Only depend on stable inputs - }, [cacheKey, backgroundRefresh, getCachedData, fetchData]); - - /** 🔹 Background refresh every 20s (if enabled) */ - useEffect(() => { - if (!backgroundRefresh) return; - - const interval = setInterval(() => { - if (!document.hidden) { - fetchData(true); - } - }, 20000); - - return () => clearInterval(interval); - }, [fetchData, backgroundRefresh]); - - /** 🔹 Manual refetch */ - const refetch = useCallback(async () => { - await fetchData(false); - }, [fetchData]); - - return { data, error, refetch }; -} diff --git a/components/hooks/useTokenBalance.ts b/components/hooks/useTokenBalance.ts index cf43067..63ea4fa 100644 --- a/components/hooks/useTokenBalance.ts +++ b/components/hooks/useTokenBalance.ts @@ -1,216 +1,298 @@ -import { useCallback, useMemo } from "react"; -import { useWalletData } from "./useWalletData"; +import { useMemo, useCallback } from 'react'; +import { useApiQuery } from './useApiQuery'; +import useExchangeRates from '@/components/hooks/useExchangeRate'; +import { + normalizeChain, + getTokenSymbol, + getTokenName, + getRateKey, + formatTokenAmount +} from '@/lib/utils/token-utils'; +import { apiClient } from '@/lib/api-client'; + +export interface TokenInfo { + chain: string; + name: string; + symbol: string; + address: string; + network: string; + balance: number; + ngnValue: number; + hasWallet: boolean; + rate: number; +} export function useTokenBalance() { - const { - addresses, - balances, - breakdown, - - } = useWalletData(); - - const getTokenSymbol = useCallback((chain: string): string => { - const key = chain ? chain.toLowerCase() : chain; - const symbolMap: { [key: string]: string } = { - ethereum: "ETH", - bitcoin: "BTC", - solana: "SOL", - starknet: "STRK", - usdt_erc20: "USDT", - usdt_trc20: "USDT", - polkadot: "DOT", - stellar: "XLM", - }; - return symbolMap[key] || (chain ? chain.toUpperCase() : ""); - }, []); - - // Normalize a provided chain or symbol into a canonical chain key used in the app - const normalizeChain = useCallback((raw: string | undefined | null) => { - if (!raw) return ""; - const k = String(raw).toLowerCase().trim(); - if (k === "sol" || k === "solana" || k.startsWith("sol")) return "solana"; - if (k === "eth" || k === "ethereum") return "ethereum"; - if (k === "btc" || k === "bitcoin") return "bitcoin"; - if (k === "strk" || k === "starknet") return "starknet"; - if (k === "usdt" || k === "usdt_erc20" || k === "usdt-erc20") return "usdt_erc20"; - if (k === "usdt_trc20" || k === "usdt-trc20" || k === "usdttrc20") return "usdt_trc20"; - if (k === "dot" || k === "polkadot") return "polkadot"; - if (k === "xlm" || k === "stellar") return "stellar"; - return k; - }, []); - - const getTokenName = useCallback((chain: string): string => { - const key = chain ? chain.toLowerCase() : chain; - const nameMap: { [key: string]: string } = { - ethereum: "Ethereum", - bitcoin: "Bitcoin", - solana: "Solana", - starknet: "Starknet", - usdt_erc20: "USDT ERC20", - usdt_trc20: "USDT TRC20", - polkadot: "Polkadot", - stellar: "Stellar", - }; - return nameMap[key] || (chain ? chain.charAt(0).toUpperCase() + chain.slice(1) : ""); - }, []); - - // CHANGED: Get balance from balances array instead of breakdown - const getWalletBalance = useCallback( - (chain: string): number => { - if (!balances || !Array.isArray(balances) || balances.length === 0) return 0; - const key = normalizeChain(chain); - const balanceInfo = balances.find( - (b) => normalizeChain(b.chain) === key || normalizeChain(b.symbol) === key - ); - return parseFloat(balanceInfo?.balance || "0"); - }, - [balances, normalizeChain] + // Fetch addresses using useApiQuery (handles caching, loading, errors automatically) + const { + data: addressesData, + isLoading: addressesLoading, + error: addressesError, + refetch: refetchAddresses + } = useApiQuery( + () => apiClient.getWalletAddresses(), + { cacheKey: 'wallet-addresses', ttl: 10 * 60 * 1000 } ); + + // Fetch balances using useApiQuery + const { + data: balancesData, + isLoading: balancesLoading, + error: balancesError, + refetch: refetchBalances + } = useApiQuery( + () => apiClient.getWalletBalances(), + { cacheKey: 'wallet-balances', ttl: 2 * 60 * 1000 } + ); + + // Get exchange rates + const { rates } = useExchangeRates(); + + // Normalize data - safe defaults + const addresses = addressesData || []; + const balances = balancesData || []; - const getWalletAddress = useCallback( - (chain: string): string => { - if (!addresses || !Array.isArray(addresses)) return ""; - const key = normalizeChain(chain); - const addressInfo = addresses.find((addr) => normalizeChain(addr.chain) === key); - return addressInfo?.address || ""; - }, - [addresses, normalizeChain] + // Get token rate from exchange rates + const getTokenRate = useCallback((symbol: string): number => { + const rateKey = getRateKey(symbol); + const rate = rates[rateKey as keyof typeof rates]; + return typeof rate === 'number' ? rate : 1; + }, [rates]); + + // MAIN COMPUTATION: Build unified token info + const availableTokens = useMemo((): TokenInfo[] => { + if (!addresses.length && !balances.length) return []; + + const tokenMap = new Map(); + + // Process addresses + addresses.forEach(addr => { + const chainKey = normalizeChain(addr.chain); + if (!chainKey) return; + + const symbol = getTokenSymbol(chainKey); + const rate = getTokenRate(symbol); + + tokenMap.set(chainKey, { + chain: chainKey, + name: getTokenName(chainKey), + symbol, + address: addr.address, + network: addr.network || 'testnet', + balance: 0, // Will be updated from balances + ngnValue: 0, + hasWallet: true, + rate, + }); + }); + + // Process balances and merge + balances.forEach(bal => { + const chainKey = normalizeChain(bal.chain) || normalizeChain(bal.symbol); + if (!chainKey) return; + + const symbol = bal.symbol || getTokenSymbol(chainKey); + const balance = parseFloat(bal.balance || '0'); + const rate = getTokenRate(symbol); + const ngnValue = balance * rate; + + const existing = tokenMap.get(chainKey); + + if (existing) { + // Update existing token + existing.balance = balance; + existing.ngnValue = ngnValue; + existing.rate = rate; + // If balance record has address but addresses array doesn't + if (!existing.address && bal.address) { + existing.address = bal.address; + existing.hasWallet = true; + } + } else { + // Create new token entry + tokenMap.set(chainKey, { + chain: chainKey, + name: getTokenName(chainKey), + symbol, + address: bal.address || '', + network: bal.network || 'testnet', + balance, + ngnValue, + hasWallet: !!bal.address, + rate, + }); + } + }); + + // Convert to array, sort by value (highest first) + return Array.from(tokenMap.values()) + .sort((a, b) => b.ngnValue - a.ngnValue); + }, [addresses, balances, getTokenRate]); + + // DERIVED DATA - all memoized + const walletTokens = useMemo(() => + availableTokens.filter(token => token.hasWallet), + [availableTokens] ); - const getWalletNetwork = useCallback( - (chain: string): string => { - if (!addresses || !Array.isArray(addresses)) return "mainnet"; - const key = normalizeChain(chain); - const addressInfo = addresses.find((addr) => normalizeChain(addr.chain) === key); - return addressInfo?.network || "mainnet"; - }, - [addresses, normalizeChain] + const tokensWithBalance = useMemo(() => + availableTokens.filter(token => token.balance > 0), + [availableTokens] ); - const hasWalletForToken = useCallback( - (chain: string): boolean => { - return !!getWalletAddress(chain); - }, - [getWalletAddress] + const totalPortfolioValue = useMemo(() => + availableTokens.reduce((sum, token) => sum + token.ngnValue, 0), + [availableTokens] ); - // FIXED: Build availableTokens from both addresses and balances so chains - // that exist only in balances (e.g., Solana) are included in the dropdown. - const availableTokens = useMemo(() => { - // Also ensure balances and breakdown are arrays - const safeBalances = Array.isArray(balances) ? balances : []; - const safeBreakdown = Array.isArray(breakdown) ? breakdown : []; - - // tokenMap keyed by lowercase chain - const tokenMap: Record = {}; - - if (Array.isArray(addresses)) { - for (const addr of addresses) { - const chainKey = normalizeChain(addr.chain); - if (!chainKey || chainKey === "" || chainKey === "unknown") continue; - tokenMap[chainKey] = tokenMap[chainKey] || {}; - tokenMap[chainKey].chain = chainKey; - tokenMap[chainKey].address = addr.address; - tokenMap[chainKey].network = addr.network; - tokenMap[chainKey].hasWallet = true; - } - } + const primaryToken = useMemo(() => + availableTokens.length > 0 ? availableTokens[0] : null, + [availableTokens] + ); - for (const b of safeBalances) { - // Some backends return a non-standard chain or leave chain empty and only provide symbol. - // Prefer a normalized chain derived from b.chain, but fall back to the symbol when needed. - let chainKey = normalizeChain(b.chain); - if (!chainKey || chainKey === "" || chainKey === "unknown") { - const sym = (b.symbol || "").toLowerCase().trim(); - if (sym === "eth" || sym === "ethereum") chainKey = "ethereum"; - else if (sym === "btc" || sym === "bitcoin") chainKey = "bitcoin"; - else if (sym === "sol" || sym === "solana") chainKey = "solana"; - else if (sym === "strk" || sym === "starknet") chainKey = "starknet"; - else if (sym === "usdt") chainKey = "usdt_erc20"; - } - if (!chainKey || chainKey === "" || chainKey === "unknown") { - // Skip entries we can't normalize to a known chain - continue; - } + - tokenMap[chainKey] = tokenMap[chainKey] || {}; - tokenMap[chainKey].chain = chainKey; - tokenMap[chainKey].balance = parseFloat(b.balance || "0") || 0; - tokenMap[chainKey].symbol = tokenMap[chainKey].symbol || (b.symbol || getTokenSymbol(chainKey)); - } + // DEBUG: Test normalization + const testNormalization = (input: string) => { + const normalized = normalizeChain(input); + return normalized; + }; + + // Get token by chain - WITH DEBUG + const getTokenByChain = useCallback((chain: string): TokenInfo | null => { + const key = normalizeChain(chain); + + const found = availableTokens.find(token => token.chain === key) || null; + return found; + }, [availableTokens]); - // Attach breakdown ngn values if available - for (const br of safeBreakdown) { - const chainKey = normalizeChain(br.chain); - tokenMap[chainKey] = tokenMap[chainKey] || {}; - tokenMap[chainKey].ngnValue = br.ngnValue || 0; + // Get token by symbol - WITH DEBUG + const getTokenBySymbol = useCallback((symbol: string): TokenInfo | null => { + const normalizedSymbol = symbol.toUpperCase(); + + const found = availableTokens.find(token => + token.symbol.toUpperCase() === normalizedSymbol + ) || null; + return found; + }, [availableTokens]); + + // Combined getTokenInfo - WITH DEBUG + const getTokenInfo = useCallback((chainOrSymbol: string): TokenInfo | null => { + const chainKey = normalizeChain(chainOrSymbol); + const byChain = getTokenByChain(chainKey); + if (byChain) { + return byChain; + } + + // Try by symbol + const bySymbol = getTokenBySymbol(chainOrSymbol); + if (bySymbol) { + return bySymbol; } + return null; + }, [getTokenByChain, getTokenBySymbol]); - // Convert map to array and fill in derived fields - const tokens = Object.keys(tokenMap) - .filter((k) => k && k !== "unknown") - .map((chainKey) => { - const entry = tokenMap[chainKey]; - return { - chain: chainKey, - name: getTokenName(chainKey), - symbol: entry.symbol || getTokenSymbol(chainKey), - address: entry.address || "", - network: entry.network || "mainnet", - balance: typeof entry.balance === "number" ? entry.balance : 0, - ngnValue: entry.ngnValue || 0, - hasWallet: !!entry.address, - }; - }) - .sort((a, b) => a.chain.localeCompare(b.chain)); - - // Ensure core chains (ethereum, bitcoin, solana) are present when balances contain only symbols - const ensureFromBalances = (symList: Array<{ chain: string; sym: string }>) => { - for (const { chain, sym } of symList) { - const exists = tokens.find((t) => t.chain === chain); - if (!exists) { - const match = safeBalances.find((b) => ((b.symbol || "") + "").toLowerCase() === sym.toLowerCase()); - if (match) { - tokens.push({ - chain, - name: getTokenName(chain), - symbol: match.symbol || getTokenSymbol(chain), - address: match.address || "", - network: match.network || "mainnet", - balance: parseFloat(match.balance || "0") || 0, - ngnValue: 0, - hasWallet: !!match.address, - }); - } - } - } - }; + // Add test function + const testLookup = useCallback((input: string) => { + const result = getTokenInfo(input); + return result; + }, [getTokenInfo]); - ensureFromBalances([ - { chain: "ethereum", sym: "eth" }, - { chain: "bitcoin", sym: "btc" }, - { chain: "solana", sym: "sol" }, - ]); + // CORE FUNCTIONS (using the memoized lookups) + const getWalletBalance = useCallback((chain: string): number => + getTokenByChain(chain)?.balance || 0, + [getTokenByChain] + ); - if (typeof window !== "undefined") { - // debug: help devs see why tokens may be missing - console.debug("useTokenBalance: availableTokens", tokens); - } + const getWalletAddress = useCallback((chain: string): string => + getTokenByChain(chain)?.address || '', + [getTokenByChain] + ); + + const getWalletNetwork = useCallback((chain: string): string => + getTokenByChain(chain)?.network || 'testnet', + [getTokenByChain] + ); - return tokens; - }, [addresses, balances, breakdown, getTokenName, getTokenSymbol, normalizeChain]); + const hasWalletForToken = useCallback((chain: string): boolean => + getTokenByChain(chain)?.hasWallet || false, + [getTokenByChain] + ); + + + const isTokenSupported = useCallback((chainOrSymbol: string): boolean => { + const key = normalizeChain(chainOrSymbol); + return availableTokens.some(token => + token.chain === key || + token.symbol.toUpperCase() === chainOrSymbol.toUpperCase() + ); + }, [availableTokens]); + + // FORMATTING HELPERS + const formatBalance = useCallback((chain: string, maxDecimals = 6): string => { + const token = getTokenByChain(chain); + if (!token) return '0'; + return formatTokenAmount(token.balance, chain, { maxDecimals }); + }, [getTokenByChain]); + + const formatValue = useCallback((chain: string, currency: 'NGN' | 'USD' = 'NGN'): string => { + const token = getTokenByChain(chain); + if (!token) return currency === 'NGN' ? '₦0' : '$0'; + + const value = token.ngnValue; + const formatted = new Intl.NumberFormat('en-NG', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); + + return currency === 'NGN' ? `₦${formatted}` : `$${(value / 1500).toFixed(2)}`; + }, [getTokenByChain]); + const isLoading = addressesLoading || balancesLoading; + const error = addressesError || balancesError; + + const refetch = useCallback(async () => { + await Promise.all([refetchAddresses(), refetchBalances()]); + }, [refetchAddresses, refetchBalances]); return { - getTokenSymbol, - getTokenName, + availableTokens, + walletTokens, + tokensWithBalance, + testLookup, // Add test function + debugData: { + addresses, + balances, + availableTokens, + exchangeRates: rates + }, + getTokenBySymbol, + getTokenByChain, + getTokenInfo, + isTokenSupported, + getWalletBalance, getWalletAddress, getWalletNetwork, hasWalletForToken, - addresses: Array.isArray(addresses) ? addresses : [], - balances: Array.isArray(balances) ? balances : [], - breakdown: Array.isArray(breakdown) ? breakdown : [], - availableTokens, + + getTokenSymbol, + getTokenName, + getTokenRate: (symbol: string) => getTokenRate(symbol), + + totalPortfolioValue, + primaryToken, + + formatBalance, + formatValue, + + isLoading, + error, + refetch, + + addresses, + balances, + addressesCount: addresses.length, + balancesCount: balances.length, }; } \ No newline at end of file diff --git a/components/hooks/useTokenMonitor.ts b/components/hooks/useTokenMonitor.ts index 1fc36e7..68b1625 100644 --- a/components/hooks/useTokenMonitor.ts +++ b/components/hooks/useTokenMonitor.ts @@ -1,4 +1,3 @@ -// hooks/useTokenMonitor.ts import { useEffect, useState } from 'react'; import { tokenManager } from '../lib/api'; import { signOut } from 'next-auth/react'; diff --git a/components/hooks/useTotalBalance.tsx b/components/hooks/useTotalBalance.tsx deleted file mode 100644 index 861aa39..0000000 --- a/components/hooks/useTotalBalance.tsx +++ /dev/null @@ -1,100 +0,0 @@ -// // hooks/useTotalBalance.ts (Improved) -// import { useEffect, useState, useCallback } from "react"; -// import { useAuth } from "../context/AuthContext"; -// import useExchangeRates from "./useExchangeRate"; - -// interface BalanceBreakdown { -// chain: string; -// symbol: string; -// balance: number; -// ngnValue: number; -// rate: number | null; -// } - -// interface BalanceSummary { -// totalNGN: number; -// breakdown: BalanceBreakdown[]; -// } - -// export const useTotalBalance = () => { -// const { getWalletBalances, token } = useAuth(); -// const { rates } = useExchangeRates(); -// const [balanceSummary, setBalanceSummary] = useState({ -// totalNGN: 0, -// breakdown: [] -// }); -// const [loading, setLoading] = useState(true); -// const [error, setError] = useState(null); - -// // Map symbols to rate keys -// const getRateKey = useCallback((symbol: string): keyof typeof rates => { -// const symbolMap: { [key: string]: keyof typeof rates } = { -// 'ETH': 'ETH', -// 'BTC': 'BTC', -// 'SOL': 'SOL', -// 'STRK': 'STRK', -// 'USDT': 'USDT', -// 'USDC': 'USDC', -// 'DOT': 'DOT', -// 'XLM': 'XML' -// }; -// return symbolMap[symbol] || 'USDT'; -// }, []); - -// const calculateTotalBalance = useCallback(async () => { -// if (!token) { -// setError("Authentication required"); -// setLoading(false); -// return; -// } - -// try { -// setLoading(true); -// setError(null); - -// const balances = await getWalletBalances(); - -// const breakdown = balances.map(balance => { -// const rateKey = getRateKey(balance.symbol); -// const rate = rates[rateKey] || 1; -// const numericBalance = parseFloat(balance.balance) || 0; -// const ngnValue = numericBalance * rate; - -// return { -// chain: balance.chain, -// symbol: balance.symbol, -// balance: numericBalance, -// ngnValue, -// rate -// }; -// }); - -// const totalNGN = breakdown.reduce((sum, item) => sum + item.ngnValue, 0); - -// setBalanceSummary({ -// totalNGN, -// breakdown -// }); - -// } catch (err) { -// setError(err instanceof Error ? err.message : 'Failed to calculate balance'); -// console.error('Error calculating total balance:', err); -// } finally { -// setLoading(false); -// } -// }, [token, getWalletBalances, rates, getRateKey]); - -// useEffect(() => { -// if (token && rates.ETH !== null) { -// calculateTotalBalance(); -// } -// }, [token, rates.ETH, calculateTotalBalance]); - -// return { -// totalBalance: balanceSummary.totalNGN, -// breakdown: balanceSummary.breakdown, -// loading, -// error, -// refetch: calculateTotalBalance -// }; -// }; \ No newline at end of file diff --git a/components/hooks/useTransactions.ts b/components/hooks/useTransactions.ts index b5dd04e..eb6698f 100644 --- a/components/hooks/useTransactions.ts +++ b/components/hooks/useTransactions.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useEffect } from 'react'; import { apiClient } from '@/lib/api-client'; import { useAuth } from '@/components/context/AuthContext'; import { Transaction, TransactionHistoryResponse } from '@/types/authContext'; -import { useSilentQuery } from './useSilentQuery'; +import { useApiQuery } from './useApiQuery'; interface UseTransactionsParams { page?: number; @@ -41,7 +41,7 @@ export const useTransactions = (initialParams: UseTransactionsParams = {}): UseT data: transactionsData, error: transactionsError, refetch: refetchTransactions - } = useSilentQuery( + } = useApiQuery( () => apiClient.getTransactionHistory({ ...params, page: 1 }), { cacheKey: `transactions-${JSON.stringify({ ...params, page: 1 })}`, diff --git a/components/hooks/useWalletData.ts b/components/hooks/useWalletData.ts index 233a6cb..8674544 100644 --- a/components/hooks/useWalletData.ts +++ b/components/hooks/useWalletData.ts @@ -1,221 +1,42 @@ -import { useCallback, useMemo } from 'react'; -import { apiClient } from '@/lib/api-client'; -import { useAuth } from '@/components/context/AuthContext'; -import { WalletAddress, WalletBalance } from '@/types/authContext'; +import { useAuthQuery } from './useAuthQuery'; +import { normalizeChain, getTokenSymbol, getRateKey } from '@/lib/utils/token-utils'; import useExchangeRates from './useExchangeRate'; -import { useSilentQuery } from './useSilentQuery'; - -interface BalanceBreakdown { - chain: string; - symbol: string; - balance: number; - ngnValue: number; - rate: number | null; -} - -interface WalletData { - addresses: WalletAddress[]; - balances: WalletBalance[]; - breakdown: BalanceBreakdown[]; - totalNGN: number; - totalBalance: number; - error: string | null; - refetch: () => Promise; - isLoading: boolean; -} - -export const useWalletData = (): WalletData => { - const { token } = useAuth(); - const { rates, isLoading: ratesLoading } = useExchangeRates(); - - +import { apiClient } from '@/lib/api-client'; - // Use silent queries - const { - data: addressesData, - error: addressesError, - refetch: refetchAddresses - } = useSilentQuery( - async () => { - try { - const result = await apiClient.getWalletAddresses(); - const wallets = result.filter(address => address.chain !== "usdt_trc20") - // console.log("filleterd wallets data",wallets) - return wallets; - } catch (error) { - throw error; - } - }, - { - cacheKey: 'wallet-addresses', - ttl: 10 * 60 * 1000, - backgroundRefresh: true - } +export const useWalletData = () => { + const { data: addressesData, ...addressesRest } = useAuthQuery( + () => apiClient.getWalletAddresses(), + { cacheKey: 'wallet-addresses', ttl: 10 * 60 * 1000 } ); - const { - data: balancesData, - error: balancesError, - refetch: refetchBalances - } = useSilentQuery( - async () => { - try { - const result = await apiClient.getWalletBalances(); - const wallets = result.filter(address => address.chain !== "usdt_trc20") - // console.log("fileterd balance", wallets) - return wallets; - } catch (error) { - throw error; - } - }, - { - cacheKey: 'wallet-balances', - ttl: 2 * 60 * 1000, - backgroundRefresh: true - } + const { data: balancesData, ...balancesRest } = useAuthQuery( + () => apiClient.getWalletBalances(), + { cacheKey: 'wallet-balances', ttl: 2 * 60 * 1000 } ); - // Fast-path: read a lightweight cached copy from sessionStorage so the UI - // can render balances immediately on cold reload while network fetch runs. - // Keys are namespaced to avoid collision and are optional. - const readFallback = (key: string): T | null => { - if (typeof window === 'undefined') return null; - try { - const raw = sessionStorage.getItem(key); - if (!raw) return null; - return JSON.parse(raw) as T; - } catch (e) { - return null; - } - }; - - const fallbackAddresses = readFallback('velo.wallet.addresses'); - const fallbackBalances = readFallback('velo.wallet.balances'); - - - // SAFE data processing - const addresses = useMemo(() => { - let result: WalletAddress[] = []; - // Prefer live query data, but fall back to sessionStorage cache so the UI - // can show something instantly while the background fetch completes. - if (Array.isArray(addressesData)) { - result = addressesData; - } else if (addressesData && typeof addressesData === 'object') { - result = (addressesData as any).addresses || []; - } else if (Array.isArray(fallbackAddresses)) { - result = fallbackAddresses; - } - - return result; - }, [addressesData]); - - const balances = useMemo(() => { - let result: WalletBalance[] = []; - // Prefer live query balances, then fallback to session cache if available. - if (Array.isArray(balancesData)) { - result = balancesData; - } else if (balancesData && typeof balancesData === 'object') { - result = (balancesData as any).balances || []; - } else if (Array.isArray(fallbackBalances)) { - result = fallbackBalances; - } - - return result; - }, [balancesData]); - - // Debug: expose raw and normalized wallet data to the console to help diagnose - // missing tokens in the dropdown (temporary; remove after debugging). - if (typeof window !== 'undefined') { - console.debug('useWalletData: raw', { addressesData, balancesData }); - console.debug('useWalletData: normalized', { addresses, balances }); - } - - const error = addressesError || balancesError; - - // Map symbols to rate keys - const getRateKey = useCallback((symbol: string): keyof typeof rates => { - const symbolMap: { [key: string]: keyof typeof rates } = { - 'ETH': 'ETH', 'BTC': 'BTC', 'SOL': 'SOL', 'STRK': 'STRK', - 'USDT': 'USDT', 'USDC': 'USDC', 'DOT': 'DOT', 'XLM': 'XML' + const { rates } = useExchangeRates(); + + // Simplified processing - logic moved to utils + const addresses = addressesData || []; + const balances = balancesData || []; + + // Calculate breakdown using shared utilities + const breakdown = balances.map(balance => { + const rateKey = getRateKey(balance.symbol || 'USDT'); + const rate = rates[rateKey] || 1; + const numericBalance = parseFloat(balance.balance || "0") || 0; + const ngnValue = numericBalance * rate; + + return { + chain: balance.chain || 'unknown', + symbol: balance.symbol || 'UNKNOWN', + balance: numericBalance, + ngnValue, + rate }; - return symbolMap[symbol] || 'USDT'; - }, []); - - // Calculate breakdown - const { breakdown, totalNGN } = useMemo(() => { - - if (!Array.isArray(balances) || balances.length === 0) { - return { breakdown: [], totalNGN: 0 }; - } - - try { - const breakdownResult = balances.map(balance => { - const rateKey = getRateKey(balance.symbol || 'USDT'); - const rate = rates[rateKey] || 1; - const numericBalance = parseFloat(balance.balance || "0") || 0; - const ngnValue = numericBalance * rate; + }); - return { - chain: balance.chain || 'unknown', - symbol: balance.symbol || 'UNKNOWN', - balance: numericBalance, - ngnValue, - rate - }; - }); - - const totalNGN = breakdownResult.reduce((sum, item) => sum + item.ngnValue, 0); - - return { - breakdown: breakdownResult, - totalNGN - }; - } catch (err) { - console.error('Breakdown calculation error:', err); - return { breakdown: [], totalNGN: 0 }; - } - }, [balances, rates, getRateKey]); - - // Manual refetch - const refetch = useCallback(async () => { - if (!token) { - console.warn(' No token for refetch'); - return; - } - - try { - await Promise.all([refetchAddresses(), refetchBalances()]); - // Persist fresh results (if available) to sessionStorage for instant - // render on next load. We read the live query variables which will be - // updated by the refetch calls above. - try { - if (typeof window !== 'undefined') { - if (addressesData) sessionStorage.setItem('velo.wallet.addresses', JSON.stringify(addressesData)); - if (balancesData) sessionStorage.setItem('velo.wallet.balances', JSON.stringify(balancesData)); - } - } catch (e) { - // ignore storage failures - } - } catch (err) { - console.error(' Manual refetch failed:', err); - } - }, [token, refetchAddresses, refetchBalances]); - - // Persist any fresh query data to sessionStorage as soon as it's available so - // subsequent navigations/read reloads display instantly. - if (typeof window !== 'undefined') { - try { - if (addressesData) sessionStorage.setItem('velo.wallet.addresses', JSON.stringify(addressesData)); - if (balancesData) sessionStorage.setItem('velo.wallet.balances', JSON.stringify(balancesData)); - } catch (e) { - // ignore quota/storage issues - } - } - - // Temporary loading state for debugging - const isLoading = !addressesData && !balancesData && !error; - - // console.log("breakdown ", breakdown) + const totalNGN = breakdown.reduce((sum, item) => sum + item.ngnValue, 0); return { addresses, @@ -223,8 +44,10 @@ export const useWalletData = (): WalletData => { breakdown, totalNGN, totalBalance: totalNGN, - error, - refetch, - isLoading + error: addressesRest.error || balancesRest.error, + refetch: async () => { + await Promise.all([addressesRest.refetch(), balancesRest.refetch()]); + }, + isLoading: addressesRest.isLoading || balancesRest.isLoading, }; }; \ No newline at end of file diff --git a/components/modals/TransactionStatus.tsx b/components/modals/TransactionStatus.tsx new file mode 100644 index 0000000..a009db7 --- /dev/null +++ b/components/modals/TransactionStatus.tsx @@ -0,0 +1,514 @@ +// /components/transactions/TransactionStatus.tsx +import React from 'react'; +import { + Check, + TriangleAlert, + AlertCircle, + ArrowUpRight, + X, + Loader2, + ExternalLink, + Info, + ShieldAlert +} from "lucide-react"; +import { Button } from "@/components/ui/buttons"; +import { Card } from "@/components/ui/Card"; +import { getBlockExplorerUrl } from "@/lib/utils/qr-utils"; + +export type StatusType = "success" | "error" | "warning" | "info" | "loading" | "pending"; + +export interface TransactionStatusProps { + type: StatusType; + message: string; + txHash?: string; + network?: string; + chain?: string; + onDismiss?: () => void; + showIcon?: boolean; + showAction?: boolean; + actionText?: string; + onAction?: () => void; + className?: string; + showExplorerLink?: boolean; + autoDismiss?: boolean; + dismissAfter?: number; +} + +export interface ValidationErrorProps { + error: string | React.ReactNode; + title?: string; + showIcon?: boolean; + variant?: 'default' | 'warning' | 'danger'; + className?: string; + onRetry?: () => void; + retryText?: string; +} + +export interface SuccessMessageProps { + message: string; + txHash?: string; + explorerUrl?: string; + chain?: string; + network?: string; + title?: string; + showIcon?: boolean; + showCopyButton?: boolean; + onClose?: () => void; + className?: string; +} + +/** + * Unified Transaction Status Component + * Supports multiple status types with consistent styling + */ +export const TransactionStatus: React.FC = ({ + type, + message, + txHash, + network = "mainnet", + chain = "ethereum", + onDismiss, + showIcon = true, + showAction = false, + actionText = "View Details", + onAction, + className = "", + showExplorerLink = true, + autoDismiss = false, + dismissAfter = 5000, +}) => { + const [isVisible, setIsVisible] = React.useState(true); + + // Auto-dismiss for success messages + React.useEffect(() => { + if (autoDismiss && type === "success" && isVisible) { + const timer = setTimeout(() => { + setIsVisible(false); + onDismiss?.(); + }, dismissAfter); + return () => clearTimeout(timer); + } + }, [autoDismiss, type, dismissAfter, isVisible, onDismiss]); + + const getStatusConfig = () => { + const configs: Record = { + success: { + bg: "bg-green-500/10", + border: "border-green-500/20", + icon: , + text: "text-green-500", + iconColor: "text-green-500", + }, + error: { + bg: "bg-red-500/10", + border: "border-red-500/20", + icon: , + text: "text-red-500", + iconColor: "text-red-500", + }, + warning: { + bg: "bg-yellow-500/10", + border: "border-yellow-500/20", + icon: , + text: "text-yellow-500", + iconColor: "text-yellow-500", + }, + info: { + bg: "bg-blue-500/10", + border: "border-blue-500/20", + icon: , + text: "text-blue-500", + iconColor: "text-blue-500", + }, + loading: { + bg: "bg-gray-500/10", + border: "border-gray-500/20", + icon: , + text: "text-gray-500", + iconColor: "text-gray-500", + }, + pending: { + bg: "bg-purple-500/10", + border: "border-purple-500/20", + icon: , + text: "text-purple-500", + iconColor: "text-purple-500", + }, + }; + return configs[type]; + }; + + if (!isVisible) return null; + + const config = getStatusConfig(); + const explorerUrl = txHash ? getBlockExplorerUrl(chain, txHash, network) : undefined; + + return ( +
+
+
+ {showIcon && ( +
+ {config.icon} +
+ )} + +
+
+ {type === "loading" ? "Processing..." : type} +
+

+ {message} +

+ + {txHash && showExplorerLink && explorerUrl && explorerUrl !== "#" && ( + + View on Explorer + + + )} +
+
+ +
+ {showAction && onAction && ( + + )} + + {onDismiss && type !== "loading" && ( + + )} +
+
+
+ ); +}; + +/** + * Unified Validation Error Component + * For form validation errors and input warnings + */ +export const ValidationError: React.FC = ({ + error, + title = "Validation Error", + showIcon = true, + variant = 'default', + className = "", + onRetry, + retryText = "Try Again", +}) => { + const getVariantConfig = () => { + const configs = { + default: { + bg: "bg-red-50 dark:bg-red-500/10", + border: "border-red-200 dark:border-red-500/20", + text: "text-red-800 dark:text-red-500", + iconColor: "text-red-500", + }, + warning: { + bg: "bg-yellow-50 dark:bg-yellow-500/10", + border: "border-yellow-200 dark:border-yellow-500/20", + text: "text-yellow-800 dark:text-yellow-500", + iconColor: "text-yellow-500", + }, + danger: { + bg: "bg-red-100 dark:bg-red-500/20", + border: "border-red-300 dark:border-red-500/30", + text: "text-red-900 dark:text-red-400", + iconColor: "text-red-600 dark:text-red-400", + }, + }; + return configs[variant]; + }; + + const config = getVariantConfig(); + + return ( +
+
+ {showIcon && ( + + )} + +
+
+ {title} +
+
+ {typeof error === 'string' ? error : error} +
+ + {onRetry && ( + + )} +
+
+
+ ); +}; + +/** + * Unified Success Message Component + * For transaction success and other success states + */ +export const SuccessMessage: React.FC = ({ + message, + txHash, + explorerUrl, + chain = "ethereum", + network = "mainnet", + title = "Success!", + showIcon = true, + showCopyButton = false, + onClose, + className = "", +}) => { + const [copied, setCopied] = React.useState(false); + + const finalExplorerUrl = explorerUrl || + (txHash ? getBlockExplorerUrl(chain, txHash, network) : undefined); + + const handleCopy = async () => { + if (!txHash) return; + + try { + await navigator.clipboard.writeText(txHash); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy:", err); + } + }; + + return ( + +
+
+ {showIcon && ( +
+ +
+ )} + +
+

+ {title} +

+

+ {message} +

+ + {txHash && ( +
+
+ + {txHash.slice(0, 16)}...{txHash.slice(-8)} + + + {showCopyButton && ( + + )} +
+ + {finalExplorerUrl && finalExplorerUrl !== "#" && ( + + View transaction on explorer + + + )} +
+ )} +
+
+ + {onClose && ( + + )} +
+
+ ); +}; + +/** + * Loading State Component for Transactions + */ +export const TransactionLoading: React.FC<{ + message?: string; + showSpinner?: boolean; + className?: string; +}> = ({ + message = "Processing transaction...", + showSpinner = true, + className = "", +}) => { + return ( +
+
+ {showSpinner && ( +
+ +
+
+ )} +

{message}

+
+
+ ); +}; + +/** + * Transaction Status Indicator (Small inline version) + */ +export const StatusBadge: React.FC<{ + status: StatusType; + label?: string; + size?: 'sm' | 'md' | 'lg'; + showDot?: boolean; +}> = ({ + status, + label, + size = 'md', + showDot = true +}) => { + const sizeClasses = { + sm: 'text-xs px-2 py-0.5', + md: 'text-sm px-2.5 py-1', + lg: 'text-base px-3 py-1.5', + }; + + const statusConfig = { + success: { + bg: 'bg-green-100 dark:bg-green-500/20', + text: 'text-green-800 dark:text-green-400', + dot: 'bg-green-500', + }, + error: { + bg: 'bg-red-100 dark:bg-red-500/20', + text: 'text-red-800 dark:text-red-400', + dot: 'bg-red-500', + }, + warning: { + bg: 'bg-yellow-100 dark:bg-yellow-500/20', + text: 'text-yellow-800 dark:text-yellow-400', + dot: 'bg-yellow-500', + }, + info: { + bg: 'bg-blue-100 dark:bg-blue-500/20', + text: 'text-blue-800 dark:text-blue-400', + dot: 'bg-blue-500', + }, + loading: { + bg: 'bg-gray-100 dark:bg-gray-500/20', + text: 'text-gray-800 dark:text-gray-400', + dot: 'bg-gray-500 animate-pulse', + }, + pending: { + bg: 'bg-purple-100 dark:bg-purple-500/20', + text: 'text-purple-800 dark:text-purple-400', + dot: 'bg-purple-500', + }, + }[status]; + + return ( + + {showDot && ( + + )} + {label || status.charAt(0).toUpperCase() + status.slice(1)} + + ); +}; + +/** + * Empty Transactions State + */ +export const EmptyTransactions: React.FC<{ + title?: string; + description?: string; + icon?: React.ReactNode; + actionText?: string; + onAction?: () => void; +}> = ({ + title = "No transactions yet", + description = "Your transaction history will appear here", + icon, + actionText, + onAction, +}) => { + return ( +
+
+ {icon || } +
+

{title}

+

{description}

+ {actionText && onAction && ( + + )} +
+ ); +}; + +export default { + TransactionStatus, + ValidationError, + SuccessMessage, + TransactionLoading, + StatusBadge, + EmptyTransactions, +}; \ No newline at end of file diff --git a/components/modals/walletSAndBalance.tsx b/components/modals/walletSAndBalance.tsx new file mode 100644 index 0000000..deccdff --- /dev/null +++ b/components/modals/walletSAndBalance.tsx @@ -0,0 +1,182 @@ +"use client"; + +import React, { memo, useMemo, useCallback } from "react"; +import { useTokenBalance } from "../hooks"; +import chroma from "chroma-js"; +import useExchangeRates from "../hooks/useExchangeRate"; +import { formatToNGN } from "@/lib/utils"; +import { Loader2 } from "lucide-react"; + +const TokenCard = memo(({ + token, + isSelected, + baseColor, + onSelect, + getBalance, + calculateNGN +}: { + token: any; + isSelected: boolean; + baseColor: string; + onSelect: (chain: string) => void; + getBalance: (chain: string) => string; + calculateNGN: (bal: number, chain: string) => number; +}) => { + const textColor = useMemo(() => + chroma.contrast(baseColor, "#FFFFFF") > 4.5 ? "#FFFFFF" : "#000000", + [baseColor] + ); + + const gradient = useMemo(() => { + const darker = chroma(baseColor).darken(0.3).hex(); + const lighter = chroma(baseColor).brighten(0.3).hex(); + return `linear-gradient(135deg, ${darker} 0%, ${baseColor} 50%, ${lighter} 100%)`; + }, [baseColor]); + + const shadow = useMemo(() => { + const shadowColor = chroma(baseColor).darken(0.5).alpha(0.3).css(); + return `0 10px 25px -5px ${shadowColor}, 0 5px 10px 2px ${shadowColor}`; + }, [baseColor]); + + const handleClick = useCallback(() => { + onSelect(token.chain); + }, [onSelect, token.chain]); + + return ( + + ); +}); + +TokenCard.displayName = "TokenCard"; + +function WalletSAndBalance({ + selectedToken, + setSelectedToken, +}: { + selectedToken: string | null; + setSelectedToken: (arg: string) => void; +}) { + const { rates } = useExchangeRates(); + const { + tokensWithBalance, + isLoading, + availableTokens, + balances, + getTokenSymbol, + } = useTokenBalance(); + + console.log("WalletSAndBalance render", { + tokensCount: tokensWithBalance?.length, + selectedToken, + isLoading + }); + + const calculateNGN = useCallback((bal: number | string, chain: string) => { + if (!rates || Object.keys(rates).length === 0) return 0; + const tokenSymbol = getTokenSymbol(chain).toUpperCase(); + const rate = rates[tokenSymbol as keyof typeof rates]; + if (!rate) { + console.warn(`No rate found for ${tokenSymbol} (chain: ${chain})`); + return 0; + } + const balance = typeof bal === "string" ? parseFloat(bal) : bal; + const ngnValue = balance * rate; + return ngnValue; + }, [rates, getTokenSymbol]); + + const tokenColors = useMemo(() => { + const colorMap = new Map(); + if (tokensWithBalance.length > 0) { + tokensWithBalance.forEach((token, index) => { + const hue = (index * (360 / tokensWithBalance.length)) % 360; + const color = chroma.hsl(hue, 0.7, 0.6).hex(); + colorMap.set(token.chain, color); + }); + } + return colorMap; + }, [tokensWithBalance]); + + const getBalance = useCallback((chain: string) => { + const balance = balances?.find((bal) => bal.chain === chain); + return parseFloat(balance?.balance || "0").toFixed(4); + }, [balances]); + + const handleTokenSelect = useCallback((chain: string) => { + console.log("Selecting token:", chain); + setSelectedToken(chain); + }, [setSelectedToken]); + + if (isLoading) { + return ( +
+ {[...Array(4)].map((_, i) => ( +
+ +
+ ))} +
+ ); + } + + if (availableTokens.length === 0) { + return ( +
No tokens available
+ ); + } + + if (tokensWithBalance.length === 0) { + return ( +
+ No tokens with balance available +
+ ); + } + + return ( +
+ {tokensWithBalance.map((token) => ( + + ))} +
+ ); +} + +export default memo(WalletSAndBalance); \ No newline at end of file diff --git a/components/providers/ethereum-provider.ts b/components/providers/ethereum-provider.ts deleted file mode 100644 index 605ab1d..0000000 --- a/components/providers/ethereum-provider.ts +++ /dev/null @@ -1,223 +0,0 @@ -// providers/ethereum-provider.ts -import { ethers } from "ethers"; -import { BlockchainProvider, WalletMonitorResult, Transaction } from "@/types/multi-chain"; - -const KNOWN_ERC20_TOKENS: { [address: string]: { symbol: string; decimals: number } } = { - // Native ETH (special zero address) - "0x0000000000000000000000000000000000000000": { symbol: "ETH", decimals: 18 }, - - // Sepolia testnet tokens (real addresses) - "0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9": { symbol: "WETH", decimals: 18 }, - "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238": { symbol: "USDC", decimals: 6 }, - "0xE72f3E105e475D7Db3a003FfA377aFAe9d2B7A15": { symbol: "DAI", decimals: 18 }, -}; - - -export class EthereumProvider implements BlockchainProvider { - name = "ethereum"; - chainId: string; - private provider: ethers.JsonRpcProvider; - - constructor(nodeUrl: string, chainId: string) { - this.provider = new ethers.JsonRpcProvider(nodeUrl); - this.chainId = chainId; - } - - async monitorWalletTransactions( - walletAddress: string, - fromBlock?: number - ): Promise { - const normalizedAddress = this.normalizeAddress(walletAddress); - const currentBlock = await this.provider.getBlockNumber(); - const scanFromBlock = fromBlock || Math.max(0, currentBlock - 10000); - - const transactions: Transaction[] = []; - - // Monitor ETH transfers - const ethTransfers = await this.getEthTransfers(normalizedAddress, scanFromBlock, currentBlock); - transactions.push(...ethTransfers); - - // Monitor ERC20 transfers - const erc20Transfers = await this.getERC20Transfers(normalizedAddress, scanFromBlock, currentBlock); - transactions.push(...erc20Transfers); - - return { - status: "success", - walletAddress: normalizedAddress, - transactions: transactions.sort((a, b) => b.block - a.block), - totalTransactions: transactions.length, - scannedBlocks: { from: scanFromBlock, to: currentBlock } - }; - } - - private async getEthTransfers( - walletAddress: string, - fromBlock: number, - toBlock: number - ): Promise { - const transactions: Transaction[] = []; - - try { - // Get ETH transfers by scanning transaction receipts - const filter = { - fromBlock: fromBlock, - toBlock: toBlock, - to: walletAddress - }; - - console.log(filter) - - // This is a simplified approach - in production, you might want to use a more efficient method - // like The Graph or specialized indexing services - const logs = await this.provider.getLogs({ - fromBlock: fromBlock, - toBlock: toBlock, - }); - - console.log(logs) - // Process blocks in chunks to avoid timeout - for (let blockNum = fromBlock; blockNum <= toBlock; blockNum += 1000) { - const endBlock = Math.min(blockNum + 999, toBlock); - - const block = await this.provider.getBlock(blockNum, true); - if (!block || !block.transactions) continue; - console.log(endBlock) - - for (const txHash of block.transactions) { - try { - const receipt = await this.provider.getTransactionReceipt(txHash); - const tx = await this.provider.getTransaction(txHash); - - if (receipt && tx && receipt.to && this.normalizeAddress(receipt.to) === walletAddress) { - const block = await this.provider.getBlock(receipt.blockNumber); - - transactions.push({ - hash: txHash, - block: receipt.blockNumber, - timestamp: block?.timestamp || Math.floor(Date.now() / 1000), - confirmations: toBlock - receipt.blockNumber, - from: tx.from, - to: receipt.to, - amount: ethers.formatEther(tx.value), - tokenAddress: "0x0000000000000000000000000000000000000000", - tokenSymbol: "ETH", - tokenDecimals: 18, - type: "ETH_TRANSFER" - }); - } - } catch (error) { - console.warn(`Failed to process transaction ${txHash}:`, error); - } - } - } - } catch (error) { - console.error("Error fetching ETH transfers:", error); - } - - return transactions; - } - - private async getERC20Transfers( - walletAddress: string, - fromBlock: number, - toBlock: number - ): Promise { - const transactions: Transaction[] = []; - - try { - // ERC20 Transfer event signature - const transferTopic = ethers.id("Transfer(address,address,uint256)"); - - // Get all Transfer events where the recipient is our wallet - const logs = await this.provider.getLogs({ - fromBlock: fromBlock, - toBlock: toBlock, - topics: [ - transferTopic, - null, // from - ethers.zeroPadValue(walletAddress, 32) // to - ] - }); - - for (const log of logs) { - try { - const block = await this.provider.getBlock(log.blockNumber); - const tx = await this.provider.getTransaction(log.transactionHash); - console.log(tx) - // Parse transfer parameters from event data - const from = ethers.getAddress(ethers.dataSlice(log.topics[1], 12)); - const to = ethers.getAddress(ethers.dataSlice(log.topics[2], 12)); - const amount = ethers.getBigInt(log.data); - - const tokenInfo = await this.getTokenInfo(log.address); - - transactions.push({ - hash: log.transactionHash, - block: log.blockNumber, - timestamp: block?.timestamp || Math.floor(Date.now() / 1000), - confirmations: toBlock - log.blockNumber, - from, - to, - amount: amount.toString(), - tokenAddress: log.address, - tokenSymbol: tokenInfo?.symbol, - tokenDecimals: tokenInfo?.decimals, - type: "ERC20_TRANSFER" - }); - } catch (error) { - console.warn(`Failed to process ERC20 transfer in tx ${log.transactionHash}:`, error); - } - } - } catch (error) { - console.error("Error fetching ERC20 transfers:", error); - } - - return transactions; - } - - private async getTokenInfo(tokenAddress: string): Promise<{ symbol: string; decimals: number } | null> { - const normalizedAddr = this.normalizeAddress(tokenAddress); - - // Check known tokens first - if (KNOWN_ERC20_TOKENS[normalizedAddr]) { - return KNOWN_ERC20_TOKENS[normalizedAddr]; - } - - try { - // Create minimal ERC20 interface - const erc20Interface = new ethers.Interface([ - "function symbol() view returns (string)", - "function decimals() view returns (uint8)" - ]); - - const contract = new ethers.Contract(tokenAddress, erc20Interface, this.provider); - - const [symbol, decimals] = await Promise.all([ - contract.symbol().catch(() => "UNKNOWN"), - contract.decimals().catch(() => 18) - ]); - - return { symbol, decimals }; - } catch (error) { - console.warn(`Could not fetch token info for ${tokenAddress}:`, error); - return { symbol: "UNKNOWN", decimals: 18 }; - } - } - - async testConnection(): Promise { - try { - await this.provider.getBlockNumber(); - return true; - } catch { - return false; - } - } - - normalizeAddress(address: string): string { - return ethers.getAddress(address); - } - - isValidAddress(address: string): boolean { - return ethers.isAddress(address); - } -} \ No newline at end of file diff --git a/components/providers/solana-provider.ts b/components/providers/solana-provider.ts deleted file mode 100644 index a849e36..0000000 --- a/components/providers/solana-provider.ts +++ /dev/null @@ -1,138 +0,0 @@ -// providers/solana-provider-simple.ts -import { Connection, PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js"; -import { BlockchainProvider, WalletMonitorResult, Transaction } from "@/types/multi-chain"; - -export class SolanaProvider implements BlockchainProvider { - name = "solana"; - chainId = "solana-mainnet"; - private connection: Connection; - - constructor(nodeUrl: string) { - this.connection = new Connection(nodeUrl); - } - - async monitorWalletTransactions( - walletAddress: string, - - ): Promise { - const publicKey = new PublicKey(walletAddress); - const transactions: Transaction[] = []; - - try { - // Get confirmed signatures (more reliable than getSignaturesForAddress) - const signatures = await this.connection.getConfirmedSignaturesForAddress2( - publicKey, - { limit: 50 } - ); - - for (const signatureInfo of signatures) { - try { - // Use getParsedTransaction which handles versioned transactions better - const transaction = await this.connection.getParsedTransaction( - signatureInfo.signature, - { maxSupportedTransactionVersion: 0 } - ); - - if (transaction && transaction.meta) { - const solTransfers = await this.parseTransactionTransfers(transaction, publicKey); - transactions.push(...solTransfers); - } - } catch (error) { - console.warn(`Failed to process Solana transaction ${signatureInfo.signature}:`, error); - } - } - } catch (error) { - console.error("Error monitoring Solana wallet:", error); - return { - status: "error", - walletAddress, - transactions: [], - totalTransactions: 0, - scannedBlocks: { from: 0, to: 0 }, - error: "Failed to monitor Solana wallet", - details: error instanceof Error ? error.message : "Unknown error" - }; - } - - return { - status: "success", - walletAddress: walletAddress, - transactions: transactions.sort((a, b) => b.block - a.block), - totalTransactions: transactions.length, - scannedBlocks: { from: 0, to: 0 } - }; - } - - private async parseTransactionTransfers( - transaction: any, // Using any to avoid type complexity - walletAddress: PublicKey - ): Promise { - const transfers: Transaction[] = []; - - if (!transaction.meta) return transfers; - - try { - // Simple balance change detection - const preBalances = transaction.meta.preBalances; - const postBalances = transaction.meta.postBalances; - - const accountKeys = transaction.transaction.message.accountKeys.map( - (acc: any) => new PublicKey(acc.pubkey) - ); - - for (let i = 0; i < accountKeys.length; i++) { - const account = accountKeys[i]; - - if (account.equals(walletAddress)) { - const balanceChange = postBalances[i] - preBalances[i]; - - if (balanceChange > 0) { - transfers.push({ - hash: transaction.transaction.signatures[0], - block: transaction.slot, - timestamp: transaction.blockTime || Math.floor(Date.now() / 1000), - confirmations: 0, - from: "unknown", // Simplified - you'd need complex analysis to find the sender - to: walletAddress.toBase58(), - amount: (balanceChange / LAMPORTS_PER_SOL).toString(), - tokenAddress: "SOL", - tokenSymbol: "SOL", - tokenDecimals: 9, - type: "SOL_TRANSFER" - }); - } - } - } - } catch (error) { - console.warn("Failed to parse transaction transfers:", error); - } - - return transfers; - } - - async testConnection(): Promise { - try { - await this.connection.getEpochInfo(); - return true; - } catch { - return false; - } - } - - normalizeAddress(address: string): string { - try { - return new PublicKey(address).toBase58(); - } catch { - return address; - } - } - - isValidAddress(address: string): boolean { - try { - new PublicKey(address); - return true; - } catch { - return false; - } - } -} \ No newline at end of file diff --git a/components/ui/AddressCopyButton.tsx b/components/ui/AddressCopyButton.tsx new file mode 100644 index 0000000..6a69524 --- /dev/null +++ b/components/ui/AddressCopyButton.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { Copy, Check } from "lucide-react"; +import { fixStarknetAddress } from "@/components/lib/utils"; + +interface AddressCopyButtonProps { + address: string; + chain?: string; + size?: 'sm' | 'md' | 'lg'; + showIcon?: boolean; + showText?: boolean; + className?: string; +} + +export const AddressCopyButton: React.FC = ({ + address, + chain = "", + size = 'md', + showIcon = true, + showText = true, + className = "", +}) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (!address) return; + + try { + const addressToCopy = chain.toLowerCase() === "starknet" + ? fixStarknetAddress(address, chain) + : address; + + await navigator.clipboard.writeText(addressToCopy); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy address: ", err); + } + }; + + const sizeClasses = { + sm: 'h-6 w-6 text-xs', + md: 'h-8 w-8 text-sm', + lg: 'h-10 w-10 text-base', + }; + + if (!address) return null; + + return ( + + ); +}; \ No newline at end of file diff --git a/components/ui/AddressInput.tsx b/components/ui/AddressInput.tsx new file mode 100644 index 0000000..d0384ec --- /dev/null +++ b/components/ui/AddressInput.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +interface AddressInputProps { + value: string; + onChange: (value: string) => void; + chain?: string; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export const AddressInput: React.FC = ({ + value, + onChange, + chain = '', + placeholder = 'Enter wallet address', + disabled = false, + className = '', +}) => { + const getChainLabel = () => { + if (!chain) return ''; + return chain.charAt(0).toUpperCase() + chain.slice(1); + }; + + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className="w-full p-3 rounded-lg bg-background border border-border placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors font-mono text-sm disabled:opacity-50" + /> + {chain && ( +
+ {getChainLabel()} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/components/ui/AmountInput.tsx b/components/ui/AmountInput.tsx new file mode 100644 index 0000000..f116535 --- /dev/null +++ b/components/ui/AmountInput.tsx @@ -0,0 +1,36 @@ +type AmountInputProps = { + value: string; + onChange: (value: string) => void; + currency: string; + disabled?: boolean; + placeholder?: string; +}; + +export const AmountInput = ({ + value, + onChange, + currency, + disabled, + placeholder = "0.00" +}: AmountInputProps) => { + return ( +
+ { + const value = e.target.value; + if (/^\d*\.?\d*$/.test(value)) { + onChange(value); + } + }} + placeholder={placeholder} + className="w-full p-3 rounded-lg border-border bg-muted placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors pr-20 disabled:opacity-50" + disabled={disabled} + /> +
+ {currency} +
+
+ ); +}; \ No newline at end of file diff --git a/components/ui/CardContainer.tsx b/components/ui/CardContainer.tsx new file mode 100644 index 0000000..4af699f --- /dev/null +++ b/components/ui/CardContainer.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Card } from "@/components/ui/Card"; + +interface CardContainerProps { + children: React.ReactNode; + className?: string; + blur?: boolean; + border?: boolean; +} + +export const CardContainer: React.FC = ({ + children, + className = "", + blur = true, + border = true, +}) => { + return ( + + {children} + + ); +}; + +interface InstructionCardProps { + title: string; + items: string[]; + className?: string; +} + +export const InstructionCard: React.FC = ({ + title, + items, + className = "", +}) => { + return ( + +

+ {title} +

+
    + {items.map((item, index) => ( +
  • {item}
  • + ))} +
+
+ ); +}; \ No newline at end of file diff --git a/components/ui/LoadingState.tsx b/components/ui/LoadingState.tsx new file mode 100644 index 0000000..3bda5bf --- /dev/null +++ b/components/ui/LoadingState.tsx @@ -0,0 +1,31 @@ +import { Loader2, Users } from "lucide-react"; +import { Card } from "./cards"; + +export const LoadingState = ({ message = "Loading..." }) => { + return ( +
+
+ +

{message}

+
+
+ ); +}; + +export const EmptyState = ({ + icon: Icon = Users, + title, + description +}: { + icon?: React.ComponentType<{ className?: string }>; + title: string; + description: string; +}) => { + return ( + + +

{title}

+

{description}

+
+ ); +}; \ No newline at end of file diff --git a/components/ui/StepsGuide.tsx b/components/ui/StepsGuide.tsx new file mode 100644 index 0000000..600c7f9 --- /dev/null +++ b/components/ui/StepsGuide.tsx @@ -0,0 +1,31 @@ +import { Card } from "./cards"; + +export const StepsGuide = ({ steps, title = "How It Works" }: {steps: {description: string; title: string}[], title: string}) => { + return ( + +

{title}

+
+ {steps.map((step, index) => ( + + ))} +
+
+ ); +}; + +const StepItem = ({ step, number }: {step:{ + description: string; + title: string; +}, number: number}) => ( +
+
+ {number} +
+
+

{step.title}

+

+ {step.description} +

+
+
+); \ No newline at end of file diff --git a/components/ui/notification.tsx b/components/ui/notification.tsx index 6419792..5eaf98e 100644 --- a/components/ui/notification.tsx +++ b/components/ui/notification.tsx @@ -3,12 +3,11 @@ import { Bell } from "lucide-react"; import { useAuth } from "../context/AuthContext"; import { useNotifications } from "../hooks/useNotifications"; import { apiClient } from "@/lib/api-client"; +import Link from "next/link"; -interface NotificationProps { - onclick: React.Dispatch>; -} -export default function Notification({ onclick }: NotificationProps) { + +export default function Notification() { const [count, setCount] = useState(0); const [isLoading, setIsLoading] = useState(false); const { getUnreadCount } = apiClient; @@ -64,12 +63,11 @@ export default function Notification({ onclick }: NotificationProps) { const handleview = () => { markAllAsRead(); - onclick("Notification"); }; return ( -
)} - + ); } \ No newline at end of file diff --git a/lib/api-client.ts b/lib/api-client.ts index 5d57c5c..d412738 100644 --- a/lib/api-client.ts +++ b/lib/api-client.ts @@ -53,12 +53,7 @@ import { // talks to your local backend (port 5500) while NEXT_PUBLIC_API_URL can // remain pointed to the live backend. In production we always use // NEXT_PUBLIC_API_URL. -const url = (() => { - // Always resolve to NEXT_PUBLIC_API_URL. We prefer using the explicit - // public backend URL configured in environment rather than a separate - // DEV_BACKEND_API_URL to keep runtime behavior consistent across builds. - return (process.env.NEXT_PUBLIC_API_URL as string) || ""; -})(); +const url = "https://velo-node-backend.onrender.com"; // Service types export interface SupportedNetwork { @@ -446,7 +441,7 @@ class ApiClient { // Wallet methods async getWalletAddresses(): Promise { return this.request<{ addresses: WalletAddress[] }>( - "/wallet/addresses/mainnet", + "/wallet/addresses/testnet", { method: "GET" }, { // Increase TTL to reduce repeated slow calls - addresses rarely change @@ -458,7 +453,7 @@ class ApiClient { async getWalletBalances(): Promise { return this.request<{ balances: WalletBalance[] }>( - "/wallet/balances/mainnet", + "/wallet/balances/testnet", { method: "GET" }, { // Increase balance TTL so UI doesn't hammer slow backend; balances @@ -476,7 +471,7 @@ class ApiClient { }); // Invalidate balance cache after sending transaction - this.cache.invalidateCache(["/wallet/balances/mainnet"]); + this.cache.invalidateCache(["/wallet/balances/testnet"]); return result; } @@ -739,7 +734,7 @@ class ApiClient { async checkDeploy(): Promise { return this.request( - "/checkdeploy/balances/mainnet/deploy", + "/checkdeploy/balances/testnet/deploy", { method: "GET", }, diff --git a/lib/api.ts b/lib/api.ts index 04f9d88..1eb042c 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -7,7 +7,7 @@ import { tokenManager } from "@/components/lib/api"; const API_BASE_URL = (process.env.NEXT_PUBLIC_BACKEND_URL as string) || (process.env.NEXT_PUBLIC_API_URL as string) || - ""; + "https://velo-node-backend.onrender.com"; export async function getStats(): Promise { const token = tokenManager.getToken(); diff --git a/lib/utils.ts b/lib/utils.ts index 6ccd78d..45648d5 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -27,30 +27,68 @@ export const validatePhoneNumber = (num: string): string => { const cleanNum = num.replace(/\D/g, ''); let fixedNum = cleanNum; + let originalFormat = ''; - // Case 1: Numbers starting with 0 -> replace with 234 if (cleanNum.startsWith("0")) { - if (cleanNum.length !== 11) { - throw new Error(`Invalid length: ${num}. Numbers starting with 0 should be 11 digits`); - } - fixedNum = `234${cleanNum.slice(1)}`; + originalFormat = "0-prefix"; + } else if (cleanNum.startsWith("234")) { + originalFormat = "234-prefix"; + } else if (/^[7-9][0-9]{9}$/.test(cleanNum)) { + originalFormat = "10-digit"; } - // Case 2: Numbers starting with 234 -> keep as is - else if (cleanNum.startsWith("234")) { - if (cleanNum.length !== 13) { - throw new Error(`Invalid length: ${num}. Numbers starting with 234 should be 13 digits`); - } + + if (cleanNum.startsWith("0")) { + fixedNum = `234${cleanNum.slice(1)}`; + } else if (cleanNum.startsWith("234")) { fixedNum = cleanNum; - } - // Case 3: Numbers that don't start with 0 or 234 -> throw error - else { - throw new Error(`Invalid format: ${num}. Nigerian numbers must start with 0 or 234`); + } else if (/^[7-9][0-9]{9}$/.test(cleanNum)) { + fixedNum = `234${cleanNum}`; + } else { + if (cleanNum.length === 11 && !cleanNum.startsWith("0")) { + throw new Error(`Invalid format: ${num}. Did you mean 0${cleanNum}?`); + } else if (cleanNum.length === 12 && cleanNum.startsWith("234")) { + throw new Error(`Invalid format: ${num}. Please provide all 13 digits for 234 prefix numbers`); + } else if (cleanNum.length === 9 && /^[7-9][0-9]{8}$/.test(cleanNum)) { + throw new Error(`Invalid format: ${num}. Please provide all 10 digits`); + } else { + throw new Error(`Invalid Nigerian phone number: ${num}. + Valid formats: + • 08101842464 (11 digits starting with 0) + • 8101842464 (10 digits) + • 2348101842464 (13 digits starting with 234)`); + } } - // Final validation for Nigerian number format if (!/^234[7-9][0-9]{9}$/.test(fixedNum)) { + const firstDigitAfter234 = fixedNum.charAt(3); + if (!/[7-9]/.test(firstDigitAfter234)) { + throw new Error(`Invalid Nigerian phone number: ${num}. + Nigerian numbers must start with 070, 080, 081, 090, 091, etc.`); + } + if (fixedNum.length !== 13) { + throw new Error(`Invalid length: ${num}. Expected 13 digits with 234 prefix, got ${fixedNum.length}`); + } throw new Error(`Invalid Nigerian phone number: ${num}`); } return fixedNum; +}; + + +export const formatToNGN = (amount: number = 0, decimal: boolean = true) => { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: !decimal ? 0 : 2, + }).format(amount); +}; + +export const formatToNGNCompact = (amount: number = 0, decimal: boolean = true) => { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + notation: "compact", + compactDisplay: "short", + maximumFractionDigits: !decimal ? 0 : 1, + }).format(amount); }; \ No newline at end of file diff --git a/lib/utils/qr-utils.ts b/lib/utils/qr-utils.ts new file mode 100644 index 0000000..7d7ab94 --- /dev/null +++ b/lib/utils/qr-utils.ts @@ -0,0 +1,376 @@ +import QRCodeLib from "qrcode"; + +export interface QRCodeOptions { + amount?: string | null; + label?: string | null; + width?: number; + margin?: number; + darkColor?: string; + lightColor?: string; + errorCorrectionLevel?: "L" | "M" | "Q" | "H"; +} + +export interface QRCodeResult { + dataUrl: string; + rawData: string; + format: string; +} + +export interface ExplorerUrls { + testnet: string; + mainnet: string; +} + +/** + * Generate QR code URI data for different blockchain networks + */ +export const generateQRData = ( + chain: string, + address: string, + amount?: string, + label?: string +): string => { + const normalizedChain = chain.toLowerCase(); + const normalizedAddress = address.trim(); + + switch (normalizedChain) { + case "bitcoin": + case "btc": { + let bitcoinUri = `bitcoin:${normalizedAddress}`; + const bitcoinParams = []; + if (amount) bitcoinParams.push(`amount=${amount}`); + if (label) bitcoinParams.push(`label=${encodeURIComponent(label)}`); + if (bitcoinParams.length > 0) { + bitcoinUri += `?${bitcoinParams.join("&")}`; + } + return bitcoinUri; + } + + case "ethereum": + case "eth": { + let ethereumUri = `ethereum:${normalizedAddress}`; + const ethereumParams = []; + if (amount) { + const weiAmount = (parseFloat(amount) * Math.pow(10, 18)).toString(); + ethereumParams.push(`value=${weiAmount}`); + } + if (label) ethereumParams.push(`label=${encodeURIComponent(label)}`); + if (ethereumParams.length > 0) { + ethereumUri += `?${ethereumParams.join("&")}`; + } + return ethereumUri; + } + + case "solana": + case "sol": { + let solanaUri = `solana:${normalizedAddress}`; + const solanaParams = []; + if (amount) solanaParams.push(`amount=${amount}`); + if (label) solanaParams.push(`label=${encodeURIComponent(label)}`); + if (solanaParams.length > 0) { + solanaUri += `?${solanaParams.join("&")}`; + } + return solanaUri; + } + + case "starknet": + case "strk": { + let starknetUri = `starknet:${normalizedAddress}`; + const starknetParams = []; + if (amount) { + const weiAmount = (parseFloat(amount) * Math.pow(10, 18)).toString(); + starknetParams.push(`value=${weiAmount}`); + } + if (label) starknetParams.push(`label=${encodeURIComponent(label)}`); + if (starknetParams.length > 0) { + starknetUri += `?${starknetParams.join("&")}`; + } + return starknetUri; + } + + case "stellar": + case "xlm": { + let stellarUri = `web+stellar:pay?destination=${normalizedAddress}`; + const stellarParams = []; + if (amount) { + stellarParams.push(`amount=${amount}`); + } + if (label) stellarParams.push(`memo=${encodeURIComponent(label)}`); + if (stellarParams.length > 0) { + stellarUri += `&${stellarParams.join("&")}`; + } + return stellarUri; + } + + case "polkadot": + case "dot": { + let polkadotUri = `substrate:${normalizedAddress}`; + const polkadotParams = []; + if (amount) { + polkadotParams.push(`amount=${amount}`); + } + if (label) polkadotParams.push(`label=${encodeURIComponent(label)}`); + if (polkadotParams.length > 0) { + polkadotUri += `?${polkadotParams.join("&")}`; + } + return polkadotUri; + } + + case "usdt_erc20": + case "usdt_erc": { + return `ethereum:${normalizedAddress}`; + } + + case "usdt_trc20": + case "usdt_trc": { + return `tron:${normalizedAddress}`; + } + + case "erc20": { + let erc20Uri = `ethereum:${normalizedAddress}`; + const erc20Params = []; + if (amount) { + const weiAmount = (parseFloat(amount) * Math.pow(10, 18)).toString(); + erc20Params.push(`value=${weiAmount}`); + } + if (label) erc20Params.push(`label=${encodeURIComponent(label)}`); + if (erc20Params.length > 0) { + erc20Uri += `?${erc20Params.join("&")}`; + } + return erc20Uri; + } + + case "trc20": { + let trc20Uri = `tron:${normalizedAddress}`; + const trc20Params = []; + if (amount) { + const sunAmount = (parseFloat(amount) * Math.pow(10, 6)).toString(); + trc20Params.push(`amount=${sunAmount}`); + } + if (label) trc20Params.push(`label=${encodeURIComponent(label)}`); + if (trc20Params.length > 0) { + trc20Uri += `?${trc20Params.join("&")}`; + } + return trc20Uri; + } + + default: { + // Return plain address for unsupported chains + if (amount || label) { + console.warn(`Chain "${chain}" doesn't support URI schemes with amount/label. Using plain address.`); + } + return normalizedAddress; + } + } +}; + +/** + * Generate a compatible QR code image data URL + */ +export const generateCompatibleQRCode = async ( + chain: string, + address: string, + options: QRCodeOptions = {} +): Promise => { + const { + amount = null, + label = null, + width = 200, + margin = 2, + darkColor = "#000000", + lightColor = "#FFFFFF", + errorCorrectionLevel = "M", + } = options; + + try { + const qrData = generateQRData(chain, address, amount || undefined, label || undefined); + + const qrCodeDataUrl = await QRCodeLib.toDataURL(qrData, { + width, + margin, + errorCorrectionLevel, + type: "image/png" as const, + color: { + dark: darkColor, + light: lightColor, + }, + }); + + return { + dataUrl: qrCodeDataUrl, + rawData: qrData, + format: getQRFormat(chain), + }; + } catch (error) { + console.error("Error generating compatible QR code:", error); + throw new Error(`Failed to generate QR code for ${chain}: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +/** + * Get the format description for a QR code + */ +export const getQRFormat = (chain: string): string => { + const normalizedChain = chain.toLowerCase(); + + const formatMap: Record = { + bitcoin: "BIP21 Bitcoin URI", + btc: "BIP21 Bitcoin URI", + ethereum: "EIP681 Ethereum URI", + eth: "EIP681 Ethereum URI", + solana: "Solana URI Scheme", + sol: "Solana URI Scheme", + starknet: "Ethereum-compatible URI", + strk: "Ethereum-compatible URI", + stellar: "Stellar URI Scheme", + xlm: "Stellar URI Scheme", + polkadot: "Polkadot URI Scheme", + dot: "Polkadot URI Scheme", + usdt_erc20: "ERC-20 Token URI", + usdt_erc: "ERC-20 Token URI", + usdt_trc20: "TRC-20 Token URI", + usdt_trc: "TRC-20 Token URI", + erc20: "ERC-20 Token URI", + trc20: "TRC-20 Token URI", + }; + + return formatMap[normalizedChain] || "Plain Address"; +}; + +/** + * Get block explorer URL for a transaction + */ +export const getBlockExplorerUrl = ( + chain: string, + txHash: string, + network: string = "mainnet" +): string => { + const normalizedChain = chain.toLowerCase(); + const normalizedNetwork = network.toLowerCase(); + const trimmedTxHash = txHash.trim(); + + const explorerUrls: Record = { + ethereum: { + testnet: `https://sepolia.etherscan.io/tx/${trimmedTxHash}`, + mainnet: `https://etherscan.io/tx/${trimmedTxHash}`, + }, + usdt_erc20: { + testnet: `https://sepolia.etherscan.io/tx/${trimmedTxHash}`, + mainnet: `https://etherscan.io/tx/${trimmedTxHash}`, + }, + bitcoin: { + testnet: `https://blockstream.info/testnet/tx/${trimmedTxHash}`, + mainnet: `https://blockstream.info/tx/${trimmedTxHash}`, + }, + solana: { + testnet: `https://explorer.solana.com/tx/${trimmedTxHash}?cluster=devnet`, + mainnet: `https://explorer.solana.com/tx/${trimmedTxHash}`, + }, + starknet: { + testnet: `https://sepolia.voyager.online/tx/${trimmedTxHash}`, + mainnet: `https://voyager.online/tx/${trimmedTxHash}`, + }, + stellar: { + testnet: `https://testnet.steexp.com/tx/${trimmedTxHash}`, + mainnet: `https://steexp.com/tx/${trimmedTxHash}`, + }, + polkadot: { + testnet: `https://polkadot.subscan.io/extrinsic/${trimmedTxHash}?network=westend`, + mainnet: `https://polkadot.subscan.io/extrinsic/${trimmedTxHash}`, + }, + usdt_trc20: { + testnet: `https://shasta.tronscan.org/#/transaction/${trimmedTxHash}`, + mainnet: `https://tronscan.org/#/transaction/${trimmedTxHash}`, + }, + }; + + const explorer = explorerUrls[normalizedChain]; + + if (!explorer) { + console.warn(`No explorer URL configured for chain: ${chain}`); + return "#"; + } + + const url = normalizedNetwork === "testnet" + ? explorer.testnet + : explorer.mainnet; + + return url || "#"; +}; + +/** + * Generate a simple QR code for any text/URL + */ +export const generateSimpleQRCode = async ( + text: string, + options: Omit = {} +): Promise => { + const { + width = 200, + margin = 2, + darkColor = "#000000", + lightColor = "#FFFFFF", + errorCorrectionLevel = "M", + } = options; + + try { + return await QRCodeLib.toDataURL(text, { + width, + margin, + errorCorrectionLevel, + type: "image/png" as const, + color: { + dark: darkColor, + light: lightColor, + }, + }); + } catch (error) { + console.error("Error generating simple QR code:", error); + throw error; + } +}; + +/** + * Validate if a chain supports QR URI schemes with amount + */ +export const supportsAmountInQR = (chain: string): boolean => { + const supportedChains = [ + "bitcoin", "btc", + "ethereum", "eth", + "solana", "sol", + "starknet", "strk", + "stellar", "xlm", + "polkadot", "dot", + "usdt_erc20", "usdt_erc", + "usdt_trc20", "usdt_trc", + "erc20", "trc20", + ]; + + return supportedChains.includes(chain.toLowerCase()); +}; + +/** + * Get appropriate currency symbol for amount display + */ +export const getCurrencySymbol = (chain: string): string => { + const symbolMap: Record = { + bitcoin: "BTC", + btc: "BTC", + ethereum: "ETH", + eth: "ETH", + solana: "SOL", + sol: "SOL", + starknet: "STRK", + strk: "STRK", + stellar: "XLM", + xlm: "XLM", + polkadot: "DOT", + dot: "DOT", + usdt_erc20: "USDT", + usdt_erc: "USDT", + usdt_trc20: "USDT", + usdt_trc: "USDT", + }; + + return symbolMap[chain.toLowerCase()] || chain.toUpperCase(); +}; \ No newline at end of file diff --git a/lib/utils/storage-utils.ts b/lib/utils/storage-utils.ts new file mode 100644 index 0000000..eecde1c --- /dev/null +++ b/lib/utils/storage-utils.ts @@ -0,0 +1,82 @@ +// Single source for all storage operations +export class StorageManager { + private static readonly PREFIX = 'velo'; + + static get(key: string, fallback: T | null = null): T | null { + if (typeof window === 'undefined') return fallback; + + try { + const fullKey = `${this.PREFIX}.${key}`; + const raw = sessionStorage.getItem(fullKey); + if (!raw) return fallback; + return JSON.parse(raw) as T; + } catch (e) { + console.error(`Error reading ${key} from storage:`, e); + return fallback; + } + } + + static set(key: string, value: T): void { + if (typeof window === 'undefined') return; + + try { + const fullKey = `${this.PREFIX}.${key}`; + sessionStorage.setItem(fullKey, JSON.stringify(value)); + } catch (e) { + console.error(`Error storing ${key} in storage:`, e); + } + } + + static remove(key: string): void { + if (typeof window === 'undefined') return; + + try { + const fullKey = `${this.PREFIX}.${key}`; + sessionStorage.removeItem(fullKey); + } catch (e) { + console.error(`Error removing ${key} from storage:`, e); + } + } + + static clearAll(): void { + if (typeof window === 'undefined') return; + + try { + const keys = Object.keys(sessionStorage); + keys.forEach(key => { + if (key.startsWith(`${this.PREFIX}.`)) { + sessionStorage.removeItem(key); + } + }); + } catch (e) { + console.error('Error clearing storage:', e); + } + } +} + +// Memory cache (replace duplicate implementations) +export const memoryCache = new Map< + string, + { data: any; timestamp: number; ttl: number } +>(); + +export const getMemoryCache = (key: string): T | null => { + const cached = memoryCache.get(key); + if (!cached) return null; + + const isExpired = Date.now() - cached.timestamp > cached.ttl; + if (isExpired) { + memoryCache.delete(key); + return null; + } + + return cached.data; +}; + +export const setMemoryCache = (key: string, data: T, ttl: number): void => { + memoryCache.set(key, { + data, + timestamp: Date.now(), + ttl, + }); +}; \ No newline at end of file diff --git a/lib/utils/token-utils.ts b/lib/utils/token-utils.ts new file mode 100644 index 0000000..94a1a88 --- /dev/null +++ b/lib/utils/token-utils.ts @@ -0,0 +1,274 @@ +export const normalizeChain = (raw: string | undefined | null): string => { + if (!raw) return ""; + + const k = String(raw).toLowerCase().trim(); + + if (k === "sol" || k === "solana") return "solana"; + if (k === "eth" || k === "ethereum") return "ethereum"; + if (k === "btc" || k === "bitcoin") return "bitcoin"; + if (k === "strk" || k === "starknet") return "starknet"; + if (k === "usdt" || k === "usdt_erc20" || k === "usdt-erc20") return "usdt_erc20"; + if (k === "usdt_trc20" || k === "usdt-trc20") return "usdt_trc20"; + if (k === "dot" || k === "polkadot") return "polkadot"; + if (k === "xlm" || k === "stellar") return "stellar"; + + // Partial matches (starts with) + if (k.startsWith("sol")) return "solana"; + if (k.startsWith("eth")) return "ethereum"; + if (k.startsWith("btc")) return "bitcoin"; + if (k.startsWith("strk")) return "starknet"; + if (k.startsWith("usdt")) return "usdt_erc20"; + if (k.startsWith("dot")) return "polkadot"; + if (k.startsWith("xlm")) return "stellar"; + + return k; +}; + +export const getTokenSymbol = (chain: string): string => { + if (!chain) return ""; + + const key = chain.toLowerCase(); + const symbolMap: Record = { + ethereum: "ETH", + bitcoin: "BTC", + solana: "SOL", + starknet: "STRK", + usdt_erc20: "USDT", + usdt_trc20: "USDT", + polkadot: "DOT", + stellar: "XLM", + // Add more mappings as needed + tron: "TRX", + polygon: "MATIC", + avalanche: "AVAX", + arbitrum: "ARB", + optimism: "OP", + }; + + return symbolMap[key] || chain.toUpperCase(); +}; + +export const getTokenName = (chain: string): string => { + if (!chain) return ""; + + const key = chain.toLowerCase(); + const nameMap: Record = { + ethereum: "Ethereum", + bitcoin: "Bitcoin", + solana: "Solana", + starknet: "Starknet", + usdt_erc20: "USDT (ERC20)", + usdt_trc20: "USDT (TRC20)", + polkadot: "Polkadot", + stellar: "Stellar", + tron: "TRON", + polygon: "Polygon", + avalanche: "Avalanche", + arbitrum: "Arbitrum", + optimism: "Optimism", + }; + + return nameMap[key] || chain.charAt(0).toUpperCase() + chain.slice(1); +}; + +export const getRateKey = (symbol: string): string => { + if (!symbol) return "USDT"; + + const symbolUpper = symbol.toUpperCase(); + const rateKeyMap: Record = { + ETH: "ETH", + BTC: "BTC", + SOL: "SOL", + STRK: "STRK", + USDT: "USDT", + USDC: "USDC", + DAI: "DAI", + DOT: "DOT", + XLM: "XLM", + MATIC: "MATIC", + AVAX: "AVAX", + ARB: "ARB", + OP: "OP", + TRX: "TRX", + }; + + return rateKeyMap[symbolUpper] || "USDT"; +}; + +// Get blockchain explorer URL for a token +export const getExplorerUrl = ( + chain: string, + txHash: string, + network: "testnet" | "mainnet" = "mainnet" +): string => { + const explorerUrls: Record> = { + ethereum: { + testnet: `https://sepolia.etherscan.io/tx/${txHash}`, + mainnet: `https://etherscan.io/tx/${txHash}`, + }, + bitcoin: { + testnet: `https://blockstream.info/testnet/tx/${txHash}`, + mainnet: `https://blockstream.info/tx/${txHash}`, + }, + solana: { + testnet: `https://explorer.solana.com/tx/${txHash}?cluster=devnet`, + mainnet: `https://explorer.solana.com/tx/${txHash}`, + }, + starknet: { + testnet: `https://sepolia.voyager.online/tx/${txHash}`, + mainnet: `https://voyager.online/tx/${txHash}`, + }, + polkadot: { + testnet: `https://polkascan.io/testnet/polkadot/transaction/${txHash}`, + mainnet: `https://polkascan.io/polkadot/transaction/${txHash}`, + }, + stellar: { + testnet: `https://stellar.expert/explorer/testnet/tx/${txHash}`, + mainnet: `https://stellar.expert/explorer/public/tx/${txHash}`, + }, + }; + + const normalizedChain = normalizeChain(chain); + const explorer = explorerUrls[normalizedChain]; + + if (!explorer) { + // Fallback to generic or chain-specific default + return `https://explorer.example.com/tx/${txHash}`; + } + + return explorer[network]; +}; + +// Check if chain supports smart contracts +export const supportsSmartContracts = (chain: string): boolean => { + const normalizedChain = normalizeChain(chain); + const contractChains = new Set([ + "ethereum", + "starknet", + "solana", + "polygon", + "avalanche", + "arbitrum", + "optimism", + "tron", + ]); + + return contractChains.has(normalizedChain); +}; + +// Get decimal places for a token +export const getTokenDecimals = (chain: string): number => { + const normalizedChain = normalizeChain(chain); + + const decimalsMap: Record = { + ethereum: 18, + bitcoin: 8, + solana: 9, + starknet: 18, + polkadot: 10, + stellar: 7, + tron: 6, + usdt_erc20: 6, + usdt_trc20: 6, + }; + + return decimalsMap[normalizedChain] || 18; +}; + +// Format token amount with proper decimals +export const formatTokenAmount = ( + amount: number | string, + chain: string, + options?: { + maxDecimals?: number; + showSymbol?: boolean; + } +): string => { + const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount; + const decimals = getTokenDecimals(chain); + const maxDecimals = options?.maxDecimals || Math.min(decimals, 8); + + let formatted: string; + + if (numAmount === 0) { + formatted = "0"; + } else if (numAmount < Math.pow(10, -maxDecimals)) { + formatted = `<0.${'0'.repeat(maxDecimals - 1)}1`; + } else { + formatted = numAmount.toFixed(maxDecimals).replace(/\.?0+$/, ''); + } + + if (options?.showSymbol) { + const symbol = getTokenSymbol(chain); + return `${formatted} ${symbol}`; + } + + return formatted; +}; + +// Convert between chain representations +export const chainToSymbol = (chain: string): string => getTokenSymbol(chain); +export const symbolToChain = (symbol: string): string => { + const symbolUpper = symbol.toUpperCase(); + const reverseMap: Record = { + ETH: "ethereum", + BTC: "bitcoin", + SOL: "solana", + STRK: "starknet", + USDT: "usdt_erc20", + DOT: "polkadot", + XLM: "stellar", + TRX: "tron", + MATIC: "polygon", + AVAX: "avalanche", + ARB: "arbitrum", + OP: "optimism", + }; + + return reverseMap[symbolUpper] || symbol.toLowerCase(); +}; + + + +export const getTokenRateKey = (token: string): string => { + const rateMap: Record = { + ETHEREUM: "ETH", + BITCOIN: "BTC", + SOLANA: "SOL", + STARKNET: "STRK", + USDT_TRC20: "USDT", + USDT_ERC20: "USDT", + POLKADOT: "DOT", + STELLAR: "XLM", + }; + return rateMap[token] || "USDT"; +}; + +export const getTokenChain = (token: string): string => { + const chainMap: Record ={ + ETHEREUM: "ethereum", + BITCOIN: "bitcoin", + SOLANA: "solana", + STARKNET: "starknet", + USDT_ERC20: "usdt_erc20", + USDT_TRC20: "usdt_trc20", + POLKADOT: "polkadot", + STELLAR: "stellar", + }; + return chainMap[token] || "ethereum"; +}; + +export const formatBalance = (balance: number): string => { + if (balance === 0) return "0.00"; + if (balance < 0.001) return "<0.001"; + return balance.toFixed(4); +}; + +export const formatNGN = (amount: number): string => { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); +}; diff --git a/next.config.ts b/next.config.ts index 112ebaa..c2c699e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -12,3 +12,4 @@ const nextConfig = { } module.exports = nextConfig + diff --git a/package-lock.json b/package-lock.json index 467ec06..41395f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "bip32": "^5.0.0-rc.0", "bip39": "^3.1.0", "bitcoinjs-lib": "^6.1.7", + "chroma-js": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cors": "^2.8.5", @@ -61,6 +62,7 @@ "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@types/bcrypt": "^6.0.0", + "@types/chroma-js": "^3.1.2", "@types/crypto-js": "^4.2.2", "@types/node": "^20", "@types/nodemailer": "^7.0.3", @@ -5388,7 +5390,6 @@ "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.25.0", "@noble/curves": "^1.4.2", @@ -5876,6 +5877,13 @@ "@types/node": "*" } }, + "node_modules/@types/chroma-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-3.1.2.tgz", + "integrity": "sha512-YBTQqArPN8A0niHXCwrO1z5x++a+6l0mLBykncUpr23oIPW7L4h39s6gokdK/bDrPmSh8+TjMmrhBPnyiaWPmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -5933,7 +5941,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz", "integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5974,7 +5981,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5985,7 +5991,6 @@ "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6082,7 +6087,6 @@ "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", @@ -9288,7 +9292,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10015,7 +10018,6 @@ "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", "license": "MIT", - "peer": true, "dependencies": { "base-x": "^5.0.0" } @@ -10087,7 +10089,6 @@ "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -10171,9 +10172,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001750", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz", - "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==", + "version": "1.0.30001755", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", + "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", "funding": [ { "type": "opencollective", @@ -10281,6 +10282,12 @@ "node": ">=18" } }, + "node_modules/chroma-js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz", + "integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, "node_modules/cipher-base": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", @@ -10540,8 +10547,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d": { "version": "1.0.2", @@ -11205,7 +11211,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11380,7 +11385,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -13493,9 +13497,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -14330,7 +14334,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.5.2", "@swc/helpers": "0.5.15", @@ -14527,11 +14530,10 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz", - "integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", "license": "MIT-0", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -15178,7 +15180,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.2.tgz", "integrity": "sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -15484,7 +15485,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -15510,7 +15510,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -16370,7 +16369,6 @@ "resolved": "https://registry.npmjs.org/starknet/-/starknet-7.6.4.tgz", "integrity": "sha512-FB20IaLCDbh/XomkB+19f5jmNxG+RzNdRO7QUhm7nfH81UPIt2C/MyWAlHCYkbv2wznSEb73wpxbp9tytokTgQ==", "license": "MIT", - "peer": true, "dependencies": { "@noble/curves": "1.7.0", "@noble/hashes": "1.6.0", @@ -16851,11 +16849,11 @@ } }, "node_modules/tar": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", - "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -16949,7 +16947,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17226,7 +17223,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17431,7 +17427,6 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -17442,7 +17437,6 @@ "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -17501,7 +17495,6 @@ "resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.7.tgz", "integrity": "sha512-DwJhCDpujuQuKdJ2H84VbTjEJJteaSmqsuUltsfbfdbotVfNeTE4K/qc/Wi57I9x8/2ed4JNdjEna7O6PfavRg==", "license": "MIT", - "peer": true, "dependencies": { "proxy-compare": "^3.0.1" }, @@ -17550,7 +17543,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", @@ -17930,7 +17922,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 18cb750..14b5e70 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "bip32": "^5.0.0-rc.0", "bip39": "^3.1.0", "bitcoinjs-lib": "^6.1.7", + "chroma-js": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cors": "^2.8.5", @@ -63,6 +64,7 @@ "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@types/bcrypt": "^6.0.0", + "@types/chroma-js": "^3.1.2", "@types/crypto-js": "^4.2.2", "@types/node": "^20", "@types/nodemailer": "^7.0.3", diff --git a/service/blockchain-manager.ts b/service/blockchain-manager.ts deleted file mode 100644 index e483f50..0000000 --- a/service/blockchain-manager.ts +++ /dev/null @@ -1,92 +0,0 @@ -// services/blockchain-manager.ts -import { BlockchainProvider, WalletMonitorResult } from "@/types/multi-chain"; -import { StarknetProvider } from "@/components/providers/starknet-provider"; -import { EthereumProvider } from "@/components/providers/ethereum-provider"; -import { SolanaProvider } from "@/components/providers/solana-provider"; - -export class BlockchainManager { - private providers: Map = new Map(); - - constructor() { - this.initializeProviders(); - } - - private initializeProviders() { - // Starknet - - - // Ethereum - if (process.env.ETHEREUM_NODE_URL) { - const ethereumProvider = new EthereumProvider( - process.env.ETHEREUM_NODE_URL, - process.env.ETHEREUM_CHAIN_ID || "sepolia" - ); - this.providers.set("ethereum", ethereumProvider); - } - - // Polygon (EVM-compatible) - if (process.env.POLYGON_NODE_URL) { - const polygonProvider = new EthereumProvider( - process.env.POLYGON_NODE_URL, - process.env.POLYGON_CHAIN_ID || "polygon-mumbai" - ); - this.providers.set("polygon", polygonProvider); - } - - // Solana - if (process.env.SOLANA_NODE_URL) { - const solanaProvider = new SolanaProvider(process.env.SOLANA_NODE_URL); - this.providers.set("solana", solanaProvider); -} - } - - async monitorWallet( - chain: string, - walletAddress: string, - fromBlock?: number - ): Promise { - const provider = this.providers.get(chain); - - if (!provider) { - return { - status: "error", - walletAddress, - transactions: [], - totalTransactions: 0, - scannedBlocks: { from: 0, to: 0 }, - error: `Unsupported blockchain: ${chain}. Supported: ${Array.from(this.providers.keys()).join(", ")}` - }; - } - - if (!provider.isValidAddress(walletAddress)) { - return { - status: "error", - walletAddress, - transactions: [], - totalTransactions: 0, - scannedBlocks: { from: 0, to: 0 }, - error: `Invalid address format for ${chain}` - }; - } - - return await provider.monitorWalletTransactions(walletAddress, fromBlock); - } - - getSupportedChains(): string[] { - return Array.from(this.providers.keys()); - } - - async testAllConnections(): Promise<{ [chain: string]: boolean }> { - const results: { [chain: string]: boolean } = {}; - - for (const [chain, provider] of this.providers) { - try { - results[chain] = await provider.testConnection(); - } catch { - results[chain] = false; - } - } - - return results; - } -} \ No newline at end of file diff --git a/styles/service-styles.css b/styles/service-styles.css new file mode 100644 index 0000000..356550b --- /dev/null +++ b/styles/service-styles.css @@ -0,0 +1,191 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --font-size: 16px; + --background: #ffffff; + --foreground: oklch(0.145 0 0); + --card: #ffffff; + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: #030213; + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.95 0.0058 264.53); + --secondary-foreground: #030213; + --muted: #ececf0; + --muted-foreground: #717182; + --accent: #e9ebef; + --accent-foreground: #030213; + --destructive: #d4183d; + --destructive-foreground: #ffffff; + --border: rgba(0, 0, 0, 0.1); + --input: transparent; + --input-background: #f3f3f5; + --switch-background: #cbced4; + --font-weight-medium: 500; + --font-weight-normal: 400; + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: #030213; + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --font-weight-medium: 500; + --font-weight-normal: 400; + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-input-background: var(--input-background); + --color-switch-background: var(--switch-background); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + /* body { + @apply bg-background text-foreground; + } */ +} + +/** + * Base typography. This is not applied to elements which have an ancestor with a Tailwind text class. + */ +@layer base { + :where(:not(:has([class*=' text-']), :not(:has([class^='text-'])))) { + h1 { + font-size: var(--text-2xl); + font-weight: var(--font-weight-medium); + line-height: 1.5; + } + + h2 { + font-size: var(--text-xl); + font-weight: var(--font-weight-medium); + line-height: 1.5; + } + + h3 { + font-size: var(--text-lg); + font-weight: var(--font-weight-medium); + line-height: 1.5; + } + + h4 { + font-size: var(--text-base); + font-weight: var(--font-weight-medium); + line-height: 1.5; + } + + p { + font-size: var(--text-base); + font-weight: var(--font-weight-normal); + line-height: 1.5; + } + + label { + font-size: var(--text-base); + font-weight: var(--font-weight-medium); + line-height: 1.5; + } + + button { + font-size: var(--text-base); + font-weight: var(--font-weight-medium); + line-height: 1.5; + } + + input { + font-size: var(--text-base); + font-weight: var(--font-weight-normal); + line-height: 1.5; + } + } +} + +html { + font-size: var(--font-size); +} diff --git a/types/hooks.ts b/types/hooks.ts new file mode 100644 index 0000000..11af4ac --- /dev/null +++ b/types/hooks.ts @@ -0,0 +1,12 @@ +export interface QueryResult { + data: T | null; + error: string | null; + isLoading: boolean; + refetch: () => Promise; +} + +export interface PaginatedQueryResult extends QueryResult { + hasMore: boolean; + loadMore: () => Promise; + isLoadingMore: boolean; +} \ No newline at end of file