diff --git a/app/api/aggregator.ts b/app/api/aggregator.ts index 1c1b949b..c0f37230 100644 --- a/app/api/aggregator.ts +++ b/app/api/aggregator.ts @@ -722,14 +722,28 @@ export async function migrateLocalStorageRecipients( // First, fetch existing recipients from DB to check for duplicates const existingRecipients = await fetchSavedRecipients(accessToken); const existingKeys = new Set( - existingRecipients.map( - (r) => `${r.institutionCode}-${r.accountIdentifier}`, - ), + existingRecipients.map((r) => { + if (r.type === "wallet") { + if (!r.walletAddress) { + console.warn("Wallet recipient missing walletAddress", r); + return `wallet-invalid-${r.id}`; + } + return `wallet-${r.walletAddress}`; + } else { + if (!r.institutionCode || !r.accountIdentifier) { + console.warn("Bank/mobile_money recipient missing required fields", r); + return `${r.type}-invalid-${r.id}`; + } + return `${r.institutionCode}-${r.accountIdentifier}`; + } + }), ); // Filter out duplicates - only migrate recipients that don't exist in DB const recipientsToMigrate = recipients.filter((recipient) => { - const key = `${recipient.institutionCode}-${recipient.accountIdentifier}`; + const key = recipient.type === "wallet" + ? `wallet-${recipient.walletAddress}` + : `${recipient.institutionCode}-${recipient.accountIdentifier}`; return !existingKeys.has(key); }); @@ -746,7 +760,10 @@ export async function migrateLocalStorageRecipients( await saveRecipient(recipient, accessToken); return { success: true, recipient }; } catch (error) { - console.error(`Failed to migrate recipient ${recipient.name}:`, error); + const recipientName = recipient.type === "wallet" + ? recipient.walletAddress + : recipient.name; + console.error(`Failed to migrate recipient ${recipientName}:`, error); return { success: false, recipient, error }; } }); diff --git a/app/api/v1/recipients/route.ts b/app/api/v1/recipients/route.ts index 3e11eb19..72fe71ee 100644 --- a/app/api/v1/recipients/route.ts +++ b/app/api/v1/recipients/route.ts @@ -7,6 +7,7 @@ import { trackApiError, trackBusinessEvent, } from "@/app/lib/server-analytics"; +import { isValidEvmAddressCaseInsensitive } from "@/app/lib/validation"; import type { RecipientDetailsWithId, SavedRecipientsResponse, @@ -42,20 +43,33 @@ export const GET = withRateLimit(async (request: NextRequest) => { wallet_address: walletAddress, }); - const { data: recipients, error } = await supabaseAdmin + // Fetch bank/mobile_money recipients + const { data: bankRecipients, error: bankError } = await supabaseAdmin .from("saved_recipients") .select("*") .eq("normalized_wallet_address", walletAddress) .order("created_at", { ascending: false }); - if (error) { - console.error("Supabase query error:", error); - throw error; + if (bankError) { + console.error("Supabase query error:", bankError); + throw bankError; + } + + // Fetch wallet recipients + const { data: walletRecipients, error: walletError } = await supabaseAdmin + .from("saved_wallet_recipients") + .select("*") + .eq("normalized_wallet_address", walletAddress) + .order("created_at", { ascending: false }); + + if (walletError) { + console.error("Supabase query error:", walletError); + throw walletError; } - // Transform database format to frontend format - const transformedRecipients: RecipientDetailsWithId[] = - recipients?.map((recipient) => ({ + // Transform bank/mobile_money recipients + const transformedBankRecipients: RecipientDetailsWithId[] = + bankRecipients?.map((recipient) => ({ id: recipient.id, name: recipient.name, institution: recipient.institution, @@ -64,6 +78,21 @@ export const GET = withRateLimit(async (request: NextRequest) => { type: recipient.type, })) || []; + // Transform wallet recipients + const transformedWalletRecipients: RecipientDetailsWithId[] = + walletRecipients?.map((recipient) => ({ + id: recipient.id, + type: "wallet" as const, + walletAddress: recipient.recipient_wallet_address, + name: recipient.name || "", + })) || []; + + // Combine both types of recipients + const transformedRecipients: RecipientDetailsWithId[] = [ + ...transformedBankRecipients, + ...transformedWalletRecipients, + ]; + const response: SavedRecipientsResponse = { success: true, data: transformedRecipients, @@ -124,9 +153,146 @@ export const POST = withRateLimit(async (request: NextRequest) => { }); const body = await request.json(); - const { name, institution, institutionCode, accountIdentifier, type } = + const { name, institution, institutionCode, accountIdentifier, type, walletAddress: walletAddressFromBody } = body; + // Handle wallet recipients (onramp) + if (type === "wallet") { + if (!walletAddressFromBody) { + trackApiError( + request, + "/api/v1/recipients", + "POST", + new Error("Missing required field: walletAddress"), + 400, + ); + return NextResponse.json( + { + success: false, + error: "Missing required field: walletAddress", + }, + { status: 400 }, + ); + } + + // Validate wallet address format + if (!isValidEvmAddressCaseInsensitive(walletAddressFromBody.trim())) { + trackApiError( + request, + "/api/v1/recipients", + "POST", + new Error("Invalid wallet address format"), + 400, + ); + return NextResponse.json( + { + success: false, + error: "Invalid wallet address format", + }, + { status: 400 }, + ); + } + + // Check recipient count limit (100 max per wallet) + const { count: recipientCount, error: countError } = await supabaseAdmin + .from("saved_wallet_recipients") + .select("*", { count: "exact", head: true }) + .eq("normalized_wallet_address", walletAddress); + + if (countError) { + console.error("Error checking recipient count:", countError); + throw countError; + } + + // If at limit, remove the oldest recipient before adding new one + if (recipientCount && recipientCount >= 100) { + const { data: oldestRecipient } = await supabaseAdmin + .from("saved_wallet_recipients") + .select("id") + .eq("normalized_wallet_address", walletAddress) + .order("created_at", { ascending: true }) + .limit(1) + .single(); + + if (oldestRecipient) { + await supabaseAdmin + .from("saved_wallet_recipients") + .delete() + .eq("id", oldestRecipient.id); + } + } + + // Validate name for wallet recipients + if (!name || !name.trim()) { + trackApiError( + request, + "/api/v1/recipients", + "POST", + new Error("Missing required field: name"), + 400, + ); + return NextResponse.json( + { + success: false, + error: "Missing required field: name", + }, + { status: 400 }, + ); + } + + // Insert wallet recipient into saved_wallet_recipients table + const { data, error } = await supabaseAdmin + .from("saved_wallet_recipients") + .upsert( + { + wallet_address: walletAddress, + normalized_wallet_address: walletAddress, + recipient_wallet_address: walletAddressFromBody.trim(), + normalized_recipient_wallet_address: walletAddressFromBody.toLowerCase().trim(), + name: name.trim(), + }, + { + onConflict: "normalized_wallet_address,normalized_recipient_wallet_address", + }, + ) + .select() + .single(); + + if (error) { + console.error("Supabase insert error:", error); + throw error; + } + + // Transform to frontend format + const transformedRecipient: RecipientDetailsWithId = { + id: data.id, + type: "wallet", + walletAddress: data.recipient_wallet_address, + name: data.name, + }; + + const response: SaveRecipientResponse = { + success: true, + data: transformedRecipient, + }; + + // Track successful API response + const responseTime = Date.now() - startTime; + trackApiResponse("/api/v1/recipients", "POST", 200, responseTime, { + wallet_address: walletAddress, + type: "wallet", + }); + + // Track business event + trackBusinessEvent("Recipient Saved", { + wallet_address: walletAddress, + type: "wallet", + }); + + return NextResponse.json(response, { status: 201 }); + } + + // Handle bank/mobile_money recipients (offramp) // Validate request body if ( !name || @@ -315,16 +481,44 @@ export const DELETE = withRateLimit(async (request: NextRequest) => { recipient_id: recipientId, }); - // Delete the recipient (RLS policies ensure only owner can delete) - const { error: deleteError } = await supabaseAdmin - .from("saved_recipients") - .delete() + // Check which table the recipient is in by trying to find it first + const { data: walletRecipient, error: walletQueryError } = await supabaseAdmin + .from("saved_wallet_recipients") + .select("id") .eq("id", recipientId) - .eq("normalized_wallet_address", walletAddress); + .eq("normalized_wallet_address", walletAddress) + .maybeSingle(); + + // Handle query errors (except "no rows found" which is expected) + if (walletQueryError && walletQueryError.code !== "PGRST116") { + console.error("Error querying wallet recipient:", walletQueryError); + throw walletQueryError; + } - if (deleteError) { - console.error("Error deleting recipient:", deleteError); - throw deleteError; + if (walletRecipient) { + // Delete from saved_wallet_recipients + const { error: walletDeleteError } = await supabaseAdmin + .from("saved_wallet_recipients") + .delete() + .eq("id", recipientId) + .eq("normalized_wallet_address", walletAddress); + + if (walletDeleteError) { + console.error("Error deleting wallet recipient:", walletDeleteError); + throw walletDeleteError; + } + } else { + // Delete from saved_recipients (bank/mobile_money) + const { error: bankDeleteError } = await supabaseAdmin + .from("saved_recipients") + .delete() + .eq("id", recipientId) + .eq("normalized_wallet_address", walletAddress); + + if (bankDeleteError) { + console.error("Error deleting recipient:", bankDeleteError); + throw bankDeleteError; + } } const response = { diff --git a/app/components/MainPageContent.tsx b/app/components/MainPageContent.tsx index 6949cdc4..1394c15f 100644 --- a/app/components/MainPageContent.tsx +++ b/app/components/MainPageContent.tsx @@ -10,6 +10,7 @@ import { Preloader, TransactionForm, TransactionPreview, + MakePayment, TransactionStatus, NetworkSelectionModal, CookieConsent, @@ -68,7 +69,7 @@ const PageLayout = ({ const walletAddress = isInjectedWallet ? injectedAddress : user?.linkedAccounts.find((account) => account.type === "smart_wallet") - ?.address; + ?.address; return ( <> @@ -149,30 +150,30 @@ export function MainPageContent() { // State props for child components const stateProps: StateProps = { - formValues, - setFormValues, - - rate, - setRate, - isFetchingRate, - setIsFetchingRate, - rateError, - setRateError, - - institutions, - setInstitutions, - isFetchingInstitutions, - setIsFetchingInstitutions, - - selectedRecipient, - setSelectedRecipient, - - orderId, - setOrderId, - setCreatedAt, - setTransactionStatus, - } - + formValues, + setFormValues, + + rate, + setRate, + isFetchingRate, + setIsFetchingRate, + rateError, + setRateError, + + institutions, + setInstitutions, + isFetchingInstitutions, + setIsFetchingInstitutions, + + selectedRecipient, + setSelectedRecipient, + + orderId, + setOrderId, + setCreatedAt, + setTransactionStatus, + } + useEffect(function setPageLoadingState() { setOrderId(""); setIsPageLoading(false); @@ -391,6 +392,13 @@ export function MainPageContent() { createdAt={createdAt} /> ); + case STEPS.MAKE_PAYMENT: + return ( + + ); case STEPS.STATUS: return ( void; + onSave: (name: string) => void; + walletAddress: string; + isSaving?: boolean; +} + +export function AddBeneficiaryModal({ + isOpen, + onClose, + onSave, + walletAddress, + isSaving = false, +}: AddBeneficiaryModalProps) { + const [recipientName, setRecipientName] = useState(""); + + // Reset name when modal opens/closes + useEffect(() => { + if (!isOpen) { + setRecipientName(""); + } + }, [isOpen]); + + const handleSave = () => { + const trimmedName = recipientName.trim(); + if (trimmedName) { + onSave(trimmedName); + } + }; + + const handleClose = () => { + setRecipientName(""); + onClose(); + }; + + return ( + +
+ + + Add to beneficiary list + +
+ +
+ {/* Avatar - centered at top */} +
+
+ Wallet avatar +
+
+ +
+ setRecipientName(e.target.value)} + placeholder="Enter name" + autoFocus={isOpen} + className="w-full rounded-lg border-0 bg-transparent px-4 py-3 text-center text-sm text-neutral-900 placeholder-neutral-500 outline-none dark:text-white dark:placeholder-white/40" + onKeyDown={(e) => { + if (e.key === "Enter" && recipientName.trim() && !isSaving) { + handleSave(); + } + }} + /> +
+

+ {shortenAddress(walletAddress, 6, 4)} +

+
+ +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/app/components/recipient/RecipientDetailsForm.tsx b/app/components/recipient/RecipientDetailsForm.tsx index 3ac117d7..1d74251e 100644 --- a/app/components/recipient/RecipientDetailsForm.tsx +++ b/app/components/recipient/RecipientDetailsForm.tsx @@ -3,6 +3,7 @@ import { ImSpinner } from "react-icons/im"; import { AnimatePresence, motion } from "framer-motion"; import { useEffect, useMemo, useRef, useState } from "react"; import { ArrowDown01Icon, Tick02Icon } from "hugeicons-react"; +import Image from "next/image"; import { AnimatedFeedbackItem } from "../AnimatedComponents"; import { InstitutionProps } from "@/app/types"; @@ -22,6 +23,10 @@ import { } from "@/app/api/aggregator"; import { SavedBeneficiariesModal } from "@/app/components/recipient/SavedBeneficiariesModal"; import { SelectBankModal } from "@/app/components/recipient/SelectBankModal"; +import { isValidEvmAddressCaseInsensitive } from "@/app/lib/validation"; +import { getNetworkImageUrl } from "@/app/utils"; +import { useActualTheme } from "@/app/hooks/useActualTheme"; +import { useNetwork } from "@/app/context"; export const RecipientDetailsForm = ({ formMethods, @@ -31,6 +36,9 @@ export const RecipientDetailsForm = ({ selectedRecipient, setSelectedRecipient, }, + isSwapped = false, + token, + networkName, }: RecipientDetailsFormProps) => { const { watch, @@ -40,11 +48,13 @@ export const RecipientDetailsForm = ({ } = formMethods; const { getAccessToken, ready, authenticated, user } = usePrivy(); + const { selectedNetwork } = useNetwork(); const { currency } = watch(); const institution = watch("institution"); const accountIdentifier = watch("accountIdentifier"); const recipientName = watch("recipientName"); + const walletAddress = watch("walletAddress"); const [isModalOpen, setIsModalOpen] = useState(false); @@ -78,6 +88,7 @@ export const RecipientDetailsForm = ({ const [isReturningFromPreview, setIsReturningFromPreview] = useState(false); const prevCurrencyRef = useRef(currency); + const isDark = useActualTheme(); /** * Array of institutions filtered and sorted alphabetically based on the bank search term. @@ -109,21 +120,32 @@ export const RecipientDetailsForm = ({ const selectSavedRecipient = (recipient: RecipientDetails) => { setSelectedRecipient(recipient); - setSelectedInstitution({ - name: recipient.institution, - code: recipient.institutionCode, - type: recipient.type, - }); - setValue("institution", recipient.institutionCode, { shouldDirty: true }); - setValue("accountIdentifier", recipient.accountIdentifier, { - shouldDirty: true, - }); - setValue("accountType", recipient.type, { shouldDirty: true }); - // Remove extra spaces from recipient name - recipient.name = recipient.name.replace(/\s+/g, " ").trim(); - setValue("recipientName", recipient.name, { shouldDirty: true }); - setIsManualEntry(false); + if (recipient.type === "wallet") { + // Handle wallet address selection for onramp + setValue("walletAddress", recipient.walletAddress, { + shouldDirty: true, + shouldValidate: true, + }); + } else { + // Handle bank/mobile money selection for offramp + setSelectedInstitution({ + name: recipient.institution, + code: recipient.institutionCode, + type: recipient.type, + }); + setValue("institution", recipient.institutionCode, { shouldDirty: true }); + setValue("accountIdentifier", recipient.accountIdentifier, { + shouldDirty: true, + }); + setValue("accountType", recipient.type, { shouldDirty: true }); + + // Remove extra spaces from recipient name + recipient.name = recipient.name.replace(/\s+/g, " ").trim(); + setValue("recipientName", recipient.name, { shouldDirty: true }); + setIsManualEntry(false); + } + setIsModalOpen(false); }; @@ -134,8 +156,11 @@ export const RecipientDetailsForm = ({ // Find the recipient with ID from the saved recipients const recipientWithId = savedRecipients.find( (r) => - r.accountIdentifier === recipientToDeleteParam.accountIdentifier && - r.institutionCode === recipientToDeleteParam.institutionCode, + recipientToDeleteParam.type === "wallet" + ? r.type === "wallet" && r.walletAddress === recipientToDeleteParam.walletAddress + : r.type !== "wallet" && + r.accountIdentifier === recipientToDeleteParam.accountIdentifier && + r.institutionCode === recipientToDeleteParam.institutionCode, ); if (!recipientWithId) { @@ -163,13 +188,16 @@ export const RecipientDetailsForm = ({ setSavedRecipients(updatedRecipients); - if ( - selectedRecipient?.accountIdentifier === - recipientToDeleteParam.accountIdentifier && - selectedRecipient?.institutionCode === - recipientToDeleteParam.institutionCode - ) { - setSelectedRecipient(null); + if (selectedRecipient) { + if ( + recipientToDeleteParam.type === "wallet" + ? selectedRecipient.type === "wallet" && selectedRecipient.walletAddress === recipientToDeleteParam.walletAddress + : selectedRecipient.type !== "wallet" && + selectedRecipient.accountIdentifier === recipientToDeleteParam.accountIdentifier && + selectedRecipient.institutionCode === recipientToDeleteParam.institutionCode + ) { + setSelectedRecipient(null); + } } } else { setRecipientsError("Failed to delete recipient"); @@ -210,15 +238,26 @@ export const RecipientDetailsForm = ({ try { const accessToken = await getAccessToken(); if (!accessToken) { - if (!isCancelled) setRecipientsError("Authentication required"); + if (!isCancelled) { + setSavedRecipients([]); + setRecipientsError("Authentication required"); + setIsLoadingRecipients(false); + } return; } const recipients = await fetchSavedRecipients(accessToken); - if (!isCancelled) setSavedRecipients(recipients); + + if (!isCancelled) { + setSavedRecipients(recipients); + setRecipientsError(null); + } } catch (error) { console.error("Error loading recipients:", error); - if (!isCancelled) setRecipientsError("Failed to load saved recipients"); + if (!isCancelled) { + setSavedRecipients([]); + setRecipientsError("Failed to load saved recipients"); + } } finally { if (!isCancelled) setIsLoadingRecipients(false); } @@ -253,7 +292,7 @@ export const RecipientDetailsForm = ({ !institution || !accountIdentifier || accountIdentifier.toString().length < - (selectedInstitution?.code === "SAFAKEPC" ? 6 : 10) + (selectedInstitution?.code === "SAFAKEPC" ? 6 : 10) ) return; @@ -337,117 +376,195 @@ export const RecipientDetailsForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Format network name for display (e.g., "Binance Smartchain" from "bsc") + const formatNetworkName = (network?: string): string => { + if (!network) return ""; + const networkMap: Record = { + "bsc": "Binance Smartchain", + "arbitrum-one": "Arbitrum One", + "polygon": "Polygon", + "base": "Base", + "ethereum": "Ethereum", + }; + return networkMap[network.toLowerCase()] || network; + }; + + // Filter saved recipients by type + const walletRecipients = useMemo( + () => savedRecipients.filter((r) => r.type === "wallet"), + [savedRecipients], + ); + + const bankRecipients = useMemo( + () => savedRecipients.filter((r) => r.type !== "wallet"), + [savedRecipients], + ); + return ( <>

Recipient

- -
- -
- {/* Bank */} -
+ {/* Show Select beneficiary button when there are saved recipients */} + {((isSwapped && walletRecipients.length > 0) || (!isSwapped && bankRecipients.length > 0)) && ( -
+ )} +
- {/* Account number */} -
+ {isSwapped ? ( + /* Wallet address input for onramp */ +
{ + if (!value) return true; + if (!isValidEvmAddressCaseInsensitive(value)) { + return "Invalid wallet address format"; + } + return true; }, - onChange: () => setIsManualEntry(true), })} className={classNames( "w-full rounded-xl border bg-transparent px-4 py-2.5 text-sm outline-none transition-all duration-300 placeholder:text-text-placeholder focus:outline-none dark:text-white/80 dark:placeholder:text-white/30", - errors.accountIdentifier + errors.walletAddress ? "border-input-destructive focus:border-gray-400 dark:border-input-destructive" : "border-border-input dark:border-white/20 dark:focus:border-white/40 dark:focus:ring-offset-neutral-900", )} /> + {errors.walletAddress && ( + + )} + {networkName && ( +
+
+ {selectedNetwork.chain.name} +
+ + You are on {formatNetworkName(networkName)} network + +
+ )}
-
- - {/* Account details feedback */} - - {isFetchingRecipientName ? ( -
- - -

Verifying account name...

-
-
- ) : ( - <> - {recipientName ? ( - - -

- {recipientName.toLowerCase()} + ) : ( + /* Bank/Mobile Money fields for offramp */ + <> +

+ {/* Bank */} +
+ +
+ + {/* Account number */} +
+ setIsManualEntry(true), + })} + className={classNames( + "w-full rounded-xl border bg-transparent px-4 py-2.5 text-sm outline-none transition-all duration-300 placeholder:text-text-placeholder focus:outline-none dark:text-white/80 dark:placeholder:text-white/30", + errors.accountIdentifier + ? "border-input-destructive focus:border-gray-400 dark:border-input-destructive" + : "border-border-input dark:border-white/20 dark:focus:border-white/40 dark:focus:ring-offset-neutral-900", + )} + /> +
+
+ + {/* Account details feedback */} + + {isFetchingRecipientName ? ( +
+ + +

Verifying account name...

+
+
+ ) : ( + <> + {recipientName ? ( + + +

+ {recipientName.toLowerCase()} +

+
+ + +
+ ) : recipientNameError ? ( + + ) : null} + + )} +
+ + )}
setIsModalOpen(false)} onSelectRecipient={selectSavedRecipient} - savedRecipients={savedRecipients} + savedRecipients={isSwapped ? walletRecipients : bankRecipients} onDeleteRecipient={deleteRecipient} recipientToDelete={recipientToDelete} currency={currency} institutions={institutions} isLoading={isLoadingRecipients} error={recipientsError} + isSwapped={isSwapped} + networkName={networkName} /> ); diff --git a/app/components/recipient/RecipientListItem.tsx b/app/components/recipient/RecipientListItem.tsx index 69919262..e0bb811a 100644 --- a/app/components/recipient/RecipientListItem.tsx +++ b/app/components/recipient/RecipientListItem.tsx @@ -1,73 +1,135 @@ +"use client"; + import { Button } from "@headlessui/react"; import { RecipientListItemProps } from "@/app/components/recipient/types"; -import { classNames, getRandomColor } from "@/app/utils"; +import { classNames, getRandomColor, shortenAddress, resolveEnsNameOrShorten, getAvatarImage, isWalletRecipient } from "@/app/utils"; import { Delete01Icon } from "hugeicons-react"; import { ImSpinner } from "react-icons/im"; +import Image from "next/image"; +import { useEffect, useState } from "react"; export const RecipientListItem = ({ recipient, onSelect, onDelete, isBeingDeleted, -}: RecipientListItemProps) => ( -
onSelect(recipient)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - onSelect(recipient); - } - }} - > -
-
{ + const [displayName, setDisplayName] = useState(""); + const [isResolvingEns, setIsResolvingEns] = useState(false); + + // Use type predicate for better type narrowing + const walletRecipient = isWalletRecipient(recipient); + const avatarSrc = walletRecipient ? getAvatarImage(index) : null; + + // Type guard: safely access walletAddress only for wallet recipients + const walletAddress = walletRecipient ? recipient.walletAddress : ""; + + // Resolve ENS name for wallet recipients (onramp) + useEffect(() => { + if (walletRecipient && walletAddress) { + setIsResolvingEns(true); + resolveEnsNameOrShorten(walletAddress) + .then((name) => { + setDisplayName(name); + setIsResolvingEns(false); + }) + .catch(() => { + // Fallback to first 5 chars if resolution fails + setDisplayName(walletAddress.slice(2, 7)); + setIsResolvingEns(false); + }); + } + }, [recipient, walletAddress]); + + return ( +
onSelect(recipient)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + onSelect(recipient); + } + }} + > +
+ {!walletRecipient ? ( + <> +
+ {recipient.name + .split(" ") + .filter((name) => name) + .slice(0, 2) + .map((name) => name[0].toUpperCase()) + .join("")} +
+
+

+ {recipient.name.toLowerCase()} +

+

+ {recipient.accountIdentifier} + + {recipient.institution} +

+
+ + ) : ( + <> + {avatarSrc && ( +
+ {recipient.name +
+ )} +
+

+ {recipient.name || (isResolvingEns ? ( + Resolving... + ) : ( + displayName || shortenAddress(walletAddress, 6, 4) + ))} +

+

+ {shortenAddress(walletAddress, 6, 4)} +

+
+ )} - > - {recipient.name - .split(" ") - .filter((name) => name) - .slice(0, 2) - .map((name) => name[0].toUpperCase()) - .join("")}
-
-

- {recipient.name.toLowerCase()} -

-

- {recipient.accountIdentifier} - - {recipient.institution} -

-
-
- -
-); + }`} + disabled={isBeingDeleted} + > + {isBeingDeleted ? ( + + ) : ( + + )} + +
+ ); +}; \ No newline at end of file diff --git a/app/components/recipient/SavedBeneficiariesModal.tsx b/app/components/recipient/SavedBeneficiariesModal.tsx index 835582fe..183c4929 100644 --- a/app/components/recipient/SavedBeneficiariesModal.tsx +++ b/app/components/recipient/SavedBeneficiariesModal.tsx @@ -9,6 +9,7 @@ import { AnimatedModal } from "../AnimatedComponents"; import { RecipientListItem } from "./RecipientListItem"; import { SearchInput } from "./SearchInput"; import { SavedBeneficiariesModalProps } from "./types"; +import { isBankOrMobileMoneyRecipient } from "@/app/utils"; export const SavedBeneficiariesModal = ({ isOpen, @@ -21,14 +22,43 @@ export const SavedBeneficiariesModal = ({ institutions, isLoading = false, error = null, + isSwapped = false, + networkName, }: SavedBeneficiariesModalProps) => { const [beneficiarySearchTerm, setBeneficiarySearchTerm] = useState(""); const filteredSavedRecipients = useMemo(() => { - if (!currency) return []; + if (!currency && !isSwapped) return []; const allRecipients = [...savedRecipients]; - const uniqueRecipients = allRecipients.filter( + if (isSwapped) { + const walletRecipients = allRecipients.filter((r) => r.type === "wallet"); + + const uniqueRecipients = walletRecipients.filter( + (recipient, index, self) => + index === + self.findIndex( + (r) => r.type === "wallet" && recipient.type === "wallet" && r.walletAddress === recipient.walletAddress, + ), + ); + + return uniqueRecipients.filter( + (recipient) => { + if (recipient.type === "wallet") { + const walletAddress = recipient.walletAddress.toLowerCase(); + const searchTerm = beneficiarySearchTerm.toLowerCase(); + return walletAddress.includes(searchTerm); + } + return false; + } + ); + } + + const bankRecipientsOnly = allRecipients.filter( + isBankOrMobileMoneyRecipient + ); + + const uniqueRecipients = bankRecipientsOnly.filter( (recipient, index, self) => index === self.findIndex( @@ -52,13 +82,13 @@ export const SavedBeneficiariesModal = ({ .filter((recipient) => currentBankCodes.includes(recipient.institutionCode), ); - }, [savedRecipients, beneficiarySearchTerm, currency, institutions]); + }, [savedRecipients, beneficiarySearchTerm, currency, institutions, isSwapped]); return (
- Saved beneficiaries + Saved beneficiaries{networkName ? ` (${networkName})` : ""}
@@ -84,7 +114,7 @@ export const SavedBeneficiariesModal = ({ animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} transition={{ duration: 0.2 }} - className="mt-2 h-[21rem] overflow-y-auto sm:h-[14rem]" + className="mt-2 max-h-[21rem] overflow-y-auto sm:max-h-[14rem] [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]" > {isLoading ? ( @@ -115,7 +145,7 @@ export const SavedBeneficiariesModal = ({ ) : filteredSavedRecipients.length > 0 ? ( filteredSavedRecipients.map((recipient, index) => ( )) diff --git a/app/components/recipient/types.ts b/app/components/recipient/types.ts index 10537942..11173ea2 100644 --- a/app/components/recipient/types.ts +++ b/app/components/recipient/types.ts @@ -16,6 +16,7 @@ export interface RecipientListItemProps { onSelect: (recipient: RecipientDetails) => void; onDelete: (recipient: RecipientDetails) => void; isBeingDeleted: boolean; + index?: number; } export interface SavedBeneficiariesModalProps { @@ -29,6 +30,8 @@ export interface SavedBeneficiariesModalProps { institutions: InstitutionProps[]; isLoading?: boolean; error?: string | null; + isSwapped?: boolean; // For onramp mode + networkName?: string; // Network name for wallet recipients } export type SelectBankModalProps = { diff --git a/app/hooks/useSwapButton.ts b/app/hooks/useSwapButton.ts index 98be7fb7..e03be725 100644 --- a/app/hooks/useSwapButton.ts +++ b/app/hooks/useSwapButton.ts @@ -11,6 +11,7 @@ interface UseSwapButtonProps { isUserVerified: boolean; rate?: number | null; tokenDecimals?: number; + isSwapped?: boolean; // true when in onramp mode (fiat in Send, token in Receive) } export function useSwapButton({ @@ -21,10 +22,11 @@ export function useSwapButton({ isUserVerified, rate, tokenDecimals = 18, + isSwapped = false, }: UseSwapButtonProps) { const { authenticated } = usePrivy(); const { isInjectedWallet } = useInjectedWallet(); - const { amountSent, currency, recipientName } = watch(); + const { amountSent, currency, recipientName, walletAddress } = watch(); const isAmountValid = Number(amountSent) >= 0.5; const isCurrencySelected = Boolean(currency); @@ -37,7 +39,11 @@ export function useSwapButton({ ); const totalRequired = (Number(amountSent) || 0) + senderFeeAmount; - const hasInsufficientBalance = totalRequired > balance; + // Skip balance check in onramp mode (isSwapped = true) + const hasInsufficientBalance = isSwapped ? false : totalRequired > balance; + + // Check recipient based on mode: walletAddress for onramp, recipientName for offramp + const hasRecipient = isSwapped ? Boolean(walletAddress) : Boolean(recipientName); const isEnabled = (() => { if (!rate) return false; @@ -64,7 +70,7 @@ export function useSwapButton({ if (!isDirty || !isValid || !isCurrencySelected || !isAmountValid) { return false; } - return Boolean(recipientName); + return hasRecipient; } if (!isDirty || !isValid || !isCurrencySelected || !isAmountValid) { @@ -75,7 +81,7 @@ export function useSwapButton({ return true; // Enable for login if amount and currency are valid } - return Boolean(recipientName); // Additional check for authenticated users + return hasRecipient; // Check walletAddress for onramp, recipientName for offramp })(); const buttonText = (() => { diff --git a/app/mocks.ts b/app/mocks.ts index 00809cb9..f53de333 100644 --- a/app/mocks.ts +++ b/app/mocks.ts @@ -17,7 +17,7 @@ export const acceptedCurrencies = [ name: "TZS", label: "Tanzanian Shilling (TZS)", }, - { + { name: "MWK", label: "Malawian Kwacha (MWK)", disabled: true, diff --git a/app/pages/MakePayment.tsx b/app/pages/MakePayment.tsx new file mode 100644 index 00000000..24c9e409 --- /dev/null +++ b/app/pages/MakePayment.tsx @@ -0,0 +1,323 @@ +"use client"; +import { useEffect, useState, useRef } from "react"; +import { Copy01Icon } from "hugeicons-react"; +import { PiCheck } from "react-icons/pi"; +import { ImSpinner } from "react-icons/im"; +import { + classNames, + formatCurrency, + formatNumberWithCommas, + copyToClipboard, + getCurrencySymbol, +} from "../utils"; +import { primaryBtnClasses, secondaryBtnClasses } from "../components"; +import type { TransactionPreviewProps } from "../types"; +import { useStep, useNetwork } from "../context"; +import { fetchOrderDetails } from "../api/aggregator"; + +interface PaymentAccountDetails { + provider: string; + accountNumber: string; + amount: number; + currency: string; + expiresAt: Date; +} + +export const MakePayment = ({ + stateProps, + handleBackButtonClick, +}: { + stateProps: TransactionPreviewProps["stateProps"]; + handleBackButtonClick: () => void; +}) => { + const { formValues, rate, setTransactionStatus, setCreatedAt, orderId } = stateProps; + const { setCurrentStep } = useStep(); + const { selectedNetwork } = useNetwork(); + const { amountSent, currency, token, amountReceived } = formValues; + + // Mock payment account details - will be replaced with actual API call + const [paymentDetails, setPaymentDetails] = useState({ + provider: "(4Bay7 Enterprise)", + accountNumber: "952157815", + amount: amountSent || 29000, + currency: currency || "KES", + expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes from now + }); + + const [timeRemaining, setTimeRemaining] = useState(""); + const [isPaymentSent, setIsPaymentSent] = useState(false); + const [isCheckingPayment, setIsCheckingPayment] = useState(false); + const [isAccountNumberCopied, setIsAccountNumberCopied] = useState(false); + const [isAmountCopied, setIsAmountCopied] = useState(false); + const pollingIntervalRef = useRef(null); + const pollingTimeoutRef = useRef(null); + + // Calculate time remaining + useEffect(() => { + const updateTimer = () => { + const now = new Date(); + const diff = paymentDetails.expiresAt.getTime() - now.getTime(); + + if (diff <= 0) { + setTimeRemaining("00:00"); + return; + } + + const minutes = Math.floor(diff / 60000); + const seconds = Math.floor((diff % 60000) / 1000); + setTimeRemaining( + `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`, + ); + }; + + updateTimer(); + const interval = setInterval(updateTimer, 1000); + + return () => clearInterval(interval); + }, [paymentDetails.expiresAt]); + + const handlePaymentSent = async () => { + setIsPaymentSent(true); + setIsCheckingPayment(true); + + // Mark the transaction as pending while the aggregator indexes the deposit + setTransactionStatus("pending"); + setCreatedAt(new Date().toISOString()); + + // If no orderId, navigate immediately (shouldn't happen, but safety check) + if (!orderId) { + setCurrentStep("status"); + setIsCheckingPayment(false); + return; + } + + // Poll backend to check if payment has been detected + const checkPaymentStatus = async () => { + try { + const orderDetailsResponse = await fetchOrderDetails( + selectedNetwork.chain.id, + orderId, + ); + + const status = orderDetailsResponse.data.status; + + // If status is no longer "pending", payment has been detected + if (status !== "pending") { + // TODO: Validate amount - compare UI input with backend amount + // Once backend response structure is known, add validation here: + // - Extract fiat amount from orderDetailsResponse.data (check which field contains it) + // - Compare with amountSent from UI (Number(amountSent)) + // Example structure (to be updated based on actual backend response): + // const backendAmount = /* extract from backend response */; + // const uiAmount = Number(amountSent) || 0; + // if (Math.abs(backendAmount - uiAmount) > tolerance) { + // // Handle mismatch + // } + + // Clear polling + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + if (pollingTimeoutRef.current) { + clearTimeout(pollingTimeoutRef.current); + pollingTimeoutRef.current = null; + } + + // Update status and navigate + setTransactionStatus( + status as + | "processing" + | "fulfilled" + | "validated" + | "settled" + | "refunded", + ); + setIsCheckingPayment(false); + setCurrentStep("status"); + } + } catch (error) { + console.error("Error checking payment status:", error); + // On error, still navigate to status page (it will handle polling there) + setIsCheckingPayment(false); + setCurrentStep("status"); + } + }; + + // Start polling every 3 seconds + checkPaymentStatus(); // Check immediately + pollingIntervalRef.current = setInterval(checkPaymentStatus, 3000); + + // Set a timeout to stop polling after 30 seconds and navigate anyway + pollingTimeoutRef.current = setTimeout(() => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + setIsCheckingPayment(false); + setCurrentStep("status"); + }, 30000); + }; + + // Cleanup polling on unmount + useEffect(() => { + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + } + if (pollingTimeoutRef.current) { + clearTimeout(pollingTimeoutRef.current); + } + }; + }, []); + + const currencySymbol = getCurrencySymbol(paymentDetails.currency); + const formattedAmount = `${currencySymbol} ${formatNumberWithCommas(paymentDetails.amount)}`; + + return ( +
+ {/* Header */} +
+

+ Make payment +

+

+ Use the payment details below to make payment +

+
+ + {/* Account Details Card */} +
+
+

+ Account Details +

+ +

+ Send to {formattedAmount} to the account below +

+
+ + {/* Fields Container */} +
+ {/* Provider */} +
+ +

+ Provider Bank {paymentDetails.provider} +

+
+
+ + {/* Account Number */} +
+ +
+

+ {paymentDetails.accountNumber} +

+ +
+
+
+ + {/* Amount */} +
+ +
+

+ {formattedAmount} +

+ +
+
+
+ + {/* Expiry Timer */} +
+ + This account is only for this payment. Expires in + + + {timeRemaining} + +
+
+ + {/* Action Buttons */} +
+ + +
+
+ ); +}; \ No newline at end of file diff --git a/app/pages/TransactionForm.tsx b/app/pages/TransactionForm.tsx index 2f7117a0..58328d44 100644 --- a/app/pages/TransactionForm.tsx +++ b/app/pages/TransactionForm.tsx @@ -26,8 +26,10 @@ import { formatDecimalPrecision, currencyToCountryCode, reorderCurrenciesByLocation, + fetchUserCountryCode, + mapCountryToCurrency, } from "../utils"; -import { ArrowDown02Icon, NoteEditIcon, Wallet01Icon } from "hugeicons-react"; +import { ArrowUpDownIcon, NoteEditIcon, Wallet01Icon } from "hugeicons-react"; import { useSwapButton } from "../hooks/useSwapButton"; import { fetchKYCStatus } from "../api/aggregator"; import { useCNGNRate } from "../hooks/useCNGNRate"; @@ -78,7 +80,9 @@ export const TransactionForm = ({ const [formattedSentAmount, setFormattedSentAmount] = useState(""); const [formattedReceivedAmount, setFormattedReceivedAmount] = useState(""); const isFirstRender = useRef(true); + const hasRestoredStateRef = useRef(false); const [rateError, setRateError] = useState(null); + const [isSwapped, setIsSwapped] = useState(false); // Track if swap mode is active (onramp) const currencies = useMemo( () => @@ -101,7 +105,7 @@ export const TransactionForm = ({ setValue, formState: { errors, isValid, isDirty }, } = formMethods; - const { amountSent, amountReceived, token, currency } = watch(); + const { amountSent, amountReceived, token, currency, walletAddress } = watch(); // Custom hook for CNGN rate fetching (used for validation limits when token is cNGN) const { rate: cngnRate, error: cngnRateError } = useCNGNRate({ @@ -315,19 +319,49 @@ export const TransactionForm = ({ useEffect( function calculateReceiveAmount() { if (rate && (amountSent || amountReceived)) { + // Rate format: currency per token (e.g., 1400 NGN per 1 USDC) + // When NOT swapped (offramp): Send = Token, Receive = Currency + // Formula: Receive = Send * Rate (1 USDC * 1400 = 1400 NGN) + // When swapped (onramp): Send = Currency, Receive = Token + // Formula: Receive = Send / Rate (463,284 NGN / 1400 = 330.917 USDC) + if (isReceiveInputActive) { - const calculatedAmount = Number( - (Number(amountReceived) / rate).toFixed(4), - ); - setValue("amountSent", calculatedAmount, { shouldDirty: true }); + // User is typing in Receive field + if (isSwapped) { + // Swapped: Receive = Token, so calculate Send (Currency) + // Send = Receive * Rate (20.4 USDC * 1400 = 28,560 NGN) + const calculatedAmount = Number( + (Number(amountReceived) * rate).toFixed(2), + ); + setValue("amountSent", calculatedAmount, { shouldDirty: true }); + } else { + // Not swapped: Receive = Currency, so calculate Send (Token) + // Send = Receive / Rate (1400 NGN / 1400 = 1 USDC) + const calculatedAmount = Number( + (Number(amountReceived) / rate).toFixed(4), + ); + setValue("amountSent", calculatedAmount, { shouldDirty: true }); + } } else { - const calculatedAmount = Number((rate * amountSent).toFixed(2)); - setValue("amountReceived", calculatedAmount, { shouldDirty: true }); + // User is typing in Send field + if (isSwapped) { + // Swapped: Send = Currency, so calculate Receive (Token) + // Receive = Send / Rate (463,284 NGN / 1400 = 330.917 USDC) + const calculatedAmount = Number( + (Number(amountSent) / rate).toFixed(4), + ); + setValue("amountReceived", calculatedAmount, { shouldDirty: true }); + } else { + // Not swapped: Send = Token, so calculate Receive (Currency) + // Receive = Send * Rate (1 USDC * 1400 = 1400 NGN) + const calculatedAmount = Number((rate * amountSent).toFixed(2)); + setValue("amountReceived", calculatedAmount, { shouldDirty: true }); + } } } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [amountSent, amountReceived, rate], + [amountSent, amountReceived, rate, isSwapped], ); // Register form fields @@ -440,6 +474,8 @@ export const TransactionForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [currencies]); + + const { isEnabled, buttonText, buttonAction } = useSwapButton({ watch, balance, @@ -448,6 +484,7 @@ export const TransactionForm = ({ isUserVerified, rate, tokenDecimals, + isSwapped, }); const handleSwap = () => { @@ -455,6 +492,88 @@ export const TransactionForm = ({ handleSubmit(onSubmit)(); }; + useEffect(() => { + // Only run once to restore state + if (hasRestoredStateRef.current) { + return; + } + + const shouldBeSwapped = Boolean(walletAddress); + if (shouldBeSwapped !== isSwapped) { + setIsSwapped(shouldBeSwapped); + hasRestoredStateRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Handle swap button click to switch between token/currency dropdowns + const handleSwapFields = async () => { + const currentAmountSent = amountSent; + const currentAmountReceived = amountReceived; + const willBeSwapped = !isSwapped; + + // Toggle swap mode FIRST + setIsSwapped(willBeSwapped); + + // Swap amounts + setValue("amountSent", currentAmountReceived || 0, { shouldDirty: true }); + setValue("amountReceived", currentAmountSent || 0, { shouldDirty: true }); + + // Swap formatted amounts + setFormattedSentAmount(formattedReceivedAmount); + setFormattedReceivedAmount(formattedSentAmount); + + // Set defaults only if not already selected + if (willBeSwapped) { + // Switching to onramp mode: Set currency based on user location + if (!currency) { + try { + const countryCode = await fetchUserCountryCode(); + const locationCurrency = countryCode + ? mapCountryToCurrency(countryCode) + : null; + + // Use location-based currency if available and supported, otherwise default to NGN + const defaultCurrency = locationCurrency && + currencies.find(c => c.name === locationCurrency && !c.disabled) + ? locationCurrency + : "NGN"; + + setValue("currency", defaultCurrency, { shouldDirty: true }); + } catch { + // Fallback to NGN if location detection fails + setValue("currency", "NGN", { shouldDirty: true }); + } + } + // Only set default token if not already selected + if (!token && fetchedTokens.length > 0) { + const usdcToken = fetchedTokens.find((t) => t.symbol === "USDC"); + const defaultToken = usdcToken?.symbol || fetchedTokens[0]?.symbol; + if (defaultToken) { + setValue("token", defaultToken, { shouldDirty: true }); + } + } + // Clear walletAddress when switching to onramp mode + if (!walletAddress) { + setValue("walletAddress", "", { shouldDirty: true }); + } + } else { + // Switching back to offramp mode + if (!token && fetchedTokens.length > 0) { + const usdcToken = fetchedTokens.find((t) => t.symbol === "USDC"); + const defaultToken = usdcToken?.symbol || fetchedTokens[0]?.symbol; + if (defaultToken) { + setValue("token", defaultToken, { shouldDirty: true }); + } + } + // Clear walletAddress when switching to offramp mode + setValue("walletAddress", "", { shouldDirty: true }); + } + + // Reset rate to trigger recalculation + stateProps.setRate(0); + }; + // Handle sent amount input changes interface SentAmountChangeEvent extends React.ChangeEvent { target: HTMLInputElement; @@ -582,6 +701,7 @@ export const TransactionForm = ({
@@ -601,7 +722,7 @@ export const TransactionForm = ({ Send - {token && activeBalance && ( + {token && activeBalance && !isSwapped && ( balance || errors.amountSent) - ? "text-red-500 dark:text-red-500" - : "text-neutral-900 dark:text-white/80" - }`} + className={`w-full rounded-xl border-b border-transparent bg-transparent py-2 text-2xl outline-none transition-all placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed dark:placeholder:text-white/30 ${authenticated && !isSwapped && (amountSent > balance || errors.amountSent) + ? "text-red-500 dark:text-red-500" + : "text-neutral-900 dark:text-white/80" + }`} placeholder="0" title="Enter amount to send" /> - - setValue("token", selectedToken, { shouldDirty: true }) - } - className="min-w-32" - dropdownWidth={160} - /> + {isSwapped ? ( + + setValue("currency", selectedCurrency, { shouldDirty: true }) + } + className="min-w-80" + dropdownWidth={320} + isCTA={!currency} + /> + ) : ( + + setValue("token", selectedToken, { shouldDirty: true }) + } + className="min-w-32" + dropdownWidth={160} + /> + )}
{(errors.amountSent || - (authenticated && totalRequired > balance)) && ( - - {errors.amountSent?.message || - (authenticated && totalRequired > balance - ? `Insufficient balance${senderFeeAmount > 0 ? ` (includes ${formatNumberWithCommas(senderFeeAmount)} ${token} fee)` : ""}` - : null)} - - )} + (authenticated && !isSwapped && totalRequired > balance)) && ( + + {errors.amountSent?.message || + (authenticated && !isSwapped && totalRequired > balance + ? `Insufficient balance${senderFeeAmount > 0 ? ` (includes ${formatNumberWithCommas(senderFeeAmount)} ${token} fee)` : ""}` + : null)} + + )} {/* Arrow showing swap direction */} -
+
+ {/* Amount to receive & currency */} @@ -730,14 +868,6 @@ export const TransactionForm = ({ type="text" inputMode="decimal" onChange={handleReceivedAmountChange} - onFocus={() => { - if ( - formattedReceivedAmount === "0" || - formattedReceivedAmount === "0.00" - ) { - setFormattedReceivedAmount(""); - } - }} onKeyDown={(e) => { // Special handling for the decimal point key if (e.key === "." && !formattedReceivedAmount.includes(".")) { @@ -756,30 +886,47 @@ export const TransactionForm = ({ } }} value={formattedReceivedAmount} - className={`w-full rounded-xl border-b border-transparent bg-transparent py-2 text-2xl outline-none transition-all placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed dark:placeholder:text-white/30 ${ - errors.amountReceived - ? "text-red-500 dark:text-red-500" - : "text-neutral-900 dark:text-white/80" - }`} + className={`w-full rounded-xl border-b border-transparent bg-transparent py-2 text-2xl outline-none transition-all placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed dark:placeholder:text-white/30 ${errors.amountReceived + ? "text-red-500 dark:text-red-500" + : "text-neutral-900 dark:text-white/80" + }`} placeholder="0" title="Enter amount to receive" /> - - setValue("currency", selectedCurrency, { shouldDirty: true }) - } - className="min-w-80" - isCTA={ - !currency && - (!authenticated || - (authenticated && !(totalRequired > balance))) - } - dropdownWidth={320} - /> + {isSwapped ? ( + balance))) + } + onSelect={(selectedToken) => + setValue("token", selectedToken, { shouldDirty: true }) + } + className="min-w-32" + dropdownWidth={160} + /> + ) : ( + + setValue("currency", selectedCurrency, { shouldDirty: true }) + } + className="min-w-80" + isCTA={ + !currency && + (!authenticated || + (authenticated && !(totalRequired > balance))) + } + dropdownWidth={320} + /> + )}
@@ -797,27 +944,31 @@ export const TransactionForm = ({ - {/* Memo */} -
- - { - formMethods.setValue("memo", e.target.value); - }} - value={formMethods.watch("memo")} - className={`min-h-11 w-full rounded-xl border border-gray-300 bg-transparent py-2 pl-9 pr-4 text-sm transition-all placeholder:text-text-placeholder focus-within:border-gray-400 focus:outline-none disabled:cursor-not-allowed dark:border-white/20 dark:bg-input-focus dark:placeholder:text-white/30 dark:focus-within:border-white/40 ${ - errors.memo + {/* Memo - Only show for offramp (not swapped) */} + {!isSwapped && ( +
+ + { + formMethods.setValue("memo", e.target.value); + }} + value={formMethods.watch("memo")} + className={`min-h-11 w-full rounded-xl border border-gray-300 bg-transparent py-2 pl-9 pr-4 text-sm transition-all placeholder:text-text-placeholder focus-within:border-gray-400 focus:outline-none disabled:cursor-not-allowed dark:border-white/20 dark:bg-input-focus dark:placeholder:text-white/30 dark:focus-within:border-white/40 ${errors.memo ? "text-red-500 dark:text-red-500" : "text-text-body dark:text-white/80" - }`} - placeholder="Add description (optional)" - maxLength={25} - /> -
+ }`} + placeholder="Add description (optional)" + maxLength={25} + /> +
+ )} )}
@@ -884,11 +1035,22 @@ export const TransactionForm = ({ <>No available quote ) : rate > 0 ? ( <> - 1 {token} ~{" "} - {isFetchingRate - ? "..." - : formatNumberWithCommasForDisplay(rate)}{" "} - {currency} + {isSwapped ? ( + <> + {isFetchingRate + ? "..." + : formatNumberWithCommasForDisplay(rate)}{" "} + {currency} ~ 1 {token} + + ) : ( + <> + 1 {token} ~{" "} + {isFetchingRate + ? "..." + : formatNumberWithCommasForDisplay(rate)}{" "} + {currency} + + )} ) : null}
diff --git a/app/pages/TransactionPreview.tsx b/app/pages/TransactionPreview.tsx index 19cadd77..ac3bbfab 100644 --- a/app/pages/TransactionPreview.tsx +++ b/app/pages/TransactionPreview.tsx @@ -10,11 +10,13 @@ import { classNames, formatCurrency, formatNumberWithCommas, + getCurrencySymbol, getGatewayContractAddress, getInstitutionNameByCode, getNetworkImageUrl, getRpcUrl, publicKeyEncrypt, + shortenAddress, } from "../utils"; import { useNetwork, useTokens } from "../context"; import type { @@ -88,8 +90,12 @@ export const TransactionPreview = ({ recipientName, accountIdentifier, memo, + walletAddress, } = formValues; + // Detect onramp mode: if walletAddress exists, it's an onramp transaction + const isOnramp = !!walletAddress; + const [errorMessage, setErrorMessage] = useState(""); const [errorCount, setErrorCount] = useState(0); // Used to trigger toast const [isConfirming, setIsConfirming] = useState(false); @@ -137,36 +143,60 @@ export const TransactionPreview = ({ feeRecipient: senderFeeRecipientAddress, } = calculateSenderFee(amountSent, rate, tokenDecimals ?? 18); - // Rendered tsx info - const renderedInfo = { - amount: `${formatNumberWithCommas(amountSent ?? 0)} ${token}`, - totalValue: `${formatCurrency(amountReceived ?? 0, currency, `en-${currency.slice(0, 2)}`)}`, - recipient: recipientName - .toLowerCase() - .split(" ") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "), - account: `${accountIdentifier} • ${getInstitutionNameByCode(institution, supportedInstitutions)}`, - ...(memo && { description: memo }), - ...(senderFeeAmount > 0 && { - fee: `${formatNumberWithCommas(senderFeeAmount)} ${token}`, - }), - network: selectedNetwork.chain.name, - }; + // Rendered tsx info - different for onramp vs offramp + const currencySymbol = getCurrencySymbol(currency); + const renderedInfo = isOnramp + ? { + // For onramp: You send fiat currency, receive token + amount: `${currencySymbol} ${formatNumberWithCommas(amountSent ?? 0)}`, + totalValue: `${formatNumberWithCommas(amountReceived ?? 0)} ${token}`, + rate: `${formatNumberWithCommas(rate)} ${currencySymbol} ~ 1 ${token}`, + network: selectedNetwork.chain.name, + recipient: walletAddress ? shortenAddress(walletAddress, 6, 4) : "", + ...(senderFeeAmount > 0 && { + fee: `${formatNumberWithCommas(senderFeeAmount)} ${token}`, + }), + } + : { + // For offramp: You send token, receive fiat currency + amount: `${formatNumberWithCommas(amountSent ?? 0)} ${token}`, + totalValue: `${formatCurrency(amountReceived ?? 0, currency, `en-${currency.slice(0, 2)}`)}`, + recipient: recipientName + .toLowerCase() + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "), + account: `${accountIdentifier} • ${getInstitutionNameByCode(institution, supportedInstitutions)}`, + ...(memo && { description: memo }), + ...(senderFeeAmount > 0 && { + fee: `${formatNumberWithCommas(senderFeeAmount)} ${token}`, + }), + network: selectedNetwork.chain.name, + }; const prepareCreateOrderParams = async () => { const providerId = searchParams.get("provider") || searchParams.get("PROVIDER"); - // Prepare recipient data - const recipient = { - accountIdentifier: formValues.accountIdentifier, - accountName: recipientName, - institution: formValues.institution, - memo: formValues.memo, - ...(providerId && { providerId }), - nonce: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`, - }; + // Prepare recipient data - different for onramp vs offramp + const recipient = isOnramp + ? { + // For onramp: wallet address is the recipient + accountIdentifier: walletAddress || "", + accountName: recipientName || walletAddress || "", + institution: "Wallet", + ...(providerId && { providerId }), + nonce: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`, + } + : { + // For offramp: bank/mobile money details + accountIdentifier: formValues.accountIdentifier, + accountName: recipientName, + institution: formValues.institution, + memo: formValues.memo, + ...(providerId && { providerId }), + nonce: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`, + }; // Fetch aggregator public key const publicKey = await fetchAggregatorPublicKey(); @@ -361,6 +391,12 @@ export const TransactionPreview = ({ }; const handlePaymentConfirmation = async () => { + // For onramp, navigate to Make Payment screen instead of creating order immediately + if (isOnramp) { + setCurrentStep("make_payment"); + return; + } + // Check balance including sender fee const totalRequired = amountSent + senderFeeAmount; if (totalRequired > balance) { @@ -399,21 +435,27 @@ export const TransactionPreview = ({ const transaction: TransactionCreateInput = { walletAddress: embeddedWallet.address, - transactionType: "swap", - fromCurrency: token, - toCurrency: currency, + transactionType: isOnramp ? "onramp" : "swap", + fromCurrency: isOnramp ? currency : token, + toCurrency: isOnramp ? token : currency, amountSent: Number(amountSent), amountReceived: Number(amountReceived), fee: Number(rate), - recipient: { - account_name: recipientName, - institution: getInstitutionNameByCode( - institution, - supportedInstitutions, - ) as string, - account_identifier: accountIdentifier, - ...(memo && { memo }), - }, + recipient: isOnramp + ? { + account_name: recipientName || walletAddress || "", + institution: "Wallet", + account_identifier: walletAddress || "", + } + : { + account_name: recipientName, + institution: getInstitutionNameByCode( + institution, + supportedInstitutions, + ) as string, + account_identifier: accountIdentifier, + ...(memo && { memo }), + }, status: "pending", network: selectedNetwork.chain.name, orderId: orderId, @@ -534,45 +576,72 @@ export const TransactionPreview = ({
- {Object.entries(renderedInfo).map(([key, value]) => ( -
-

- {key === "totalValue" - ? "Total value" - : key.charAt(0).toUpperCase() + key.slice(1)} -

- -

- {(key === "amount" || key === "fee") && ( - {`${token} - )} - - {key === "network" && ( - {selectedNetwork.chain.name} - )} - - {value} -

-
- ))} + {Object.entries(renderedInfo).map(([key, value]) => { + // For onramp, show token logo next to totalValue (which is the token amount) + const showTokenLogo = + (isOnramp && key === "totalValue") || + (!isOnramp && (key === "amount" || key === "fee")); + + return ( +
+

+ {key === "totalValue" + ? isOnramp + ? "Receive amount" + : "Total value" + : key === "amount" + ? "You send" + : key.charAt(0).toUpperCase() + key.slice(1)} + {key === "rate" && ( +
+ +
+
+ Rate changes dynamically after 5 minutes +
+
+
+
+
+
+ )} +

+ +
+

+ {showTokenLogo && ( + {`${token} + )} + + {key === "network" && ( + {selectedNetwork.chain.name} + )} + + {value} +

+
+
+ ); + })}
{/* Transaction detail disclaimer */}

- Ensure the details above are correct. Failed transaction due to wrong - details may attract a refund fee + {isOnramp + ? "Ensure the details above is correct. Lost funds due to incorrect wallet details cannot be recovered." + : "Ensure the details above are correct. Failed transaction due to wrong details may attract a refund fee"}

@@ -610,9 +679,8 @@ export const TransactionPreview = ({ ) : ( )}

Create Order

diff --git a/app/pages/TransactionStatus.tsx b/app/pages/TransactionStatus.tsx index 5f64c26a..099929dd 100644 --- a/app/pages/TransactionStatus.tsx +++ b/app/pages/TransactionStatus.tsx @@ -28,8 +28,10 @@ import { classNames, formatCurrency, formatNumberWithCommas, + getCurrencySymbol, getExplorerLink, getInstitutionNameByCode, + shortenAddress, } from "../utils"; import { fetchOrderDetails, @@ -43,6 +45,7 @@ import { STEPS, type OrderDetailsData, type TransactionStatusProps, + type RecipientDetails, } from "../types"; import { toast } from "sonner"; import { trackEvent } from "../hooks/analytics/client"; @@ -57,6 +60,7 @@ import { BlockFestCashbackComponent } from "../components/blockfest"; import { useBlockFestClaim } from "../context/BlockFestClaimContext"; import { useRocketStatus } from "../context/RocketStatusContext"; import { isBlockFestActive } from "../utils"; +import { AddBeneficiaryModal } from "../components/recipient/AddBeneficiaryModal"; // Allowed tokens for BlockFest cashback const ALLOWED_CASHBACK_TOKENS = new Set(["USDC", "USDT"]); @@ -136,6 +140,7 @@ export function TransactionStatus({ const [isSavingRecipient, setIsSavingRecipient] = useState(false); const [showSaveSuccess, setShowSaveSuccess] = useState(false); const [hasReindexed, setHasReindexed] = useState(false); + const [isOnrampModalOpen, setIsOnrampModalOpen] = useState(false); const reindexTimeoutRef = useRef(null); const latestRequestIdRef = useRef(0); @@ -145,10 +150,37 @@ export function TransactionStatus({ const token = watch("token") || ""; const currency = String(watch("currency")) || "USD"; const amount = watch("amountSent") || 0; - const fiat = Number(watch("amountReceived")) || 0; + const amountReceived = Number(watch("amountReceived")) || 0; const recipientName = String(watch("recipientName")) || ""; const accountIdentifier = watch("accountIdentifier") || ""; const institution = watch("institution") || ""; + const walletAddress = String(watch("walletAddress") || ""); + + // Detect if this is an onramp transaction + const isOnramp = !!walletAddress; + const recipientDisplayName = isOnramp + ? walletAddress + ? shortenAddress(walletAddress, 6, 4) + : "your wallet" + : recipientName + .toLowerCase() + .split(" ") + .map((name) => name.charAt(0).toUpperCase() + name.slice(1)) + .join(" "); + const primaryAmountDisplay = isOnramp + ? `${formatNumberWithCommas(amountReceived)} ${token}` + : `${formatNumberWithCommas(amount)} ${token}`; + const fundStatusLabel = !isOnramp + ? null + : transactionStatus === "refunded" + ? "Refunded" + : ["validated", "settled"].includes(transactionStatus) + ? "Deposited" + : "Processing"; + const shareDuration = calculateDuration(createdAt, completedAt); + const shareMessage = isOnramp + ? `Yay! I just swapped ${currency} for ${token} in ${shareDuration} on noblocks.xyz` + : `Yay! I just swapped ${token} for ${currency} in ${shareDuration} on noblocks.xyz`; // Check if recipient is already saved in the database const [isRecipientInBeneficiaries, setIsRecipientInBeneficiaries] = @@ -157,11 +189,6 @@ export function TransactionStatus({ // Check if recipient exists in saved beneficiaries useEffect(() => { const checkRecipientExists = async () => { - if (!accountIdentifier || !institution) { - setIsRecipientInBeneficiaries(false); - return; - } - try { const accessToken = await getAccessToken(); if (!accessToken) { @@ -170,12 +197,8 @@ export function TransactionStatus({ } const savedRecipients = await fetchSavedRecipients(accessToken); - const exists = savedRecipients.some( - (r) => - r.accountIdentifier === accountIdentifier && - r.institutionCode === institution, - ); - setIsRecipientInBeneficiaries(exists); + const recipient = findRecipientInSaved(savedRecipients); + setIsRecipientInBeneficiaries(!!recipient); } catch (error) { console.error("Error checking if recipient exists:", error); setIsRecipientInBeneficiaries(false); @@ -183,7 +206,7 @@ export function TransactionStatus({ }; checkRecipientExists(); - }, [accountIdentifier, institution, getAccessToken]); + }, [accountIdentifier, institution, walletAddress, isOnramp, getAccessToken]); // Scroll to top on mount useEffect(() => { @@ -272,11 +295,11 @@ export function TransactionStatus({ if (transactionStatus !== status) { setTransactionStatus( status as - | "processing" - | "fulfilled" - | "validated" - | "settled" - | "refunded", + | "processing" + | "fulfilled" + | "validated" + | "settled" + | "refunded", ); } @@ -494,15 +517,14 @@ export function TransactionStatus({

{transactionStatus}

@@ -524,22 +546,23 @@ export function TransactionStatus({ } }; - const handleAddToBeneficiariesChange = async (checked: boolean) => { - setAddToBeneficiaries(checked); - if (checked) { - await addBeneficiary(); - } else { - await removeRecipient(); + // Helper function to build recipient object based on transaction type + const buildRecipient = (name: string): RecipientDetails | null => { + if (isOnramp) { + if (!walletAddress) { + return null; + } + return { + type: "wallet", + walletAddress: walletAddress as string, + name: name, + }; } - }; - - const addBeneficiary = async () => { - setIsSavingRecipient(true); + // Handle bank/mobile_money recipients (offramp) const institutionCode = formMethods.watch("institution"); if (!institutionCode) { - setIsSavingRecipient(false); - return; + return null; } const institutionName = getInstitutionNameByCode( @@ -549,58 +572,117 @@ export function TransactionStatus({ if (!institutionName) { console.error("Institution name not found"); - setIsSavingRecipient(false); - return; + return null; } - const newRecipient = { - name: recipientName, + return { + name: name, institution: institutionName, institutionCode: String(institutionCode), accountIdentifier: String(formMethods.watch("accountIdentifier") || ""), type: (formMethods.watch("accountType") as "bank" | "mobile_money") || "bank", }; + }; - // Save recipient via API - const accessToken = await getAccessToken(); - if (accessToken) { - try { - const success = await saveRecipient(newRecipient, accessToken); - if (success) { - // Show success state - setIsSavingRecipient(false); - setShowSaveSuccess(true); - - // Hide after 2 seconds with fade out animation - setTimeout(() => { - setShowSaveSuccess(false); - // Add a small delay to allow fade out animation to complete - setTimeout(() => { - setIsRecipientInBeneficiaries(true); - }, 300); - }, 2000); - } else { - setIsSavingRecipient(false); - } - } catch (error) { - console.error("Error saving recipient:", error); - setIsSavingRecipient(false); + // Helper function to find recipient in saved recipients list + const findRecipientInSaved = ( + savedRecipients: Array, + ): (RecipientDetails & { id: string }) | undefined => { + if (isOnramp) { + if (!walletAddress) { + return undefined; } - } else { - setIsSavingRecipient(false); + return savedRecipients.find( + (r) => r.type === "wallet" && r.walletAddress === walletAddress, + ); } - }; - const removeRecipient = async () => { + // Handle bank/mobile_money recipients (offramp) const accountIdentifier = formMethods.watch("accountIdentifier"); const institutionCode = formMethods.watch("institution"); if (!accountIdentifier || !institutionCode) { - console.error("Missing account identifier or institution code"); + return undefined; + } + + return savedRecipients.find( + (r) => + r.type !== "wallet" && + r.accountIdentifier === accountIdentifier && + r.institutionCode === institutionCode, + ); + }; + + // Helper function to handle save success state + const handleSaveSuccess = () => { + setIsSavingRecipient(false); + setShowSaveSuccess(true); + + // Hide after 2 seconds with fade out animation + setTimeout(() => { + setShowSaveSuccess(false); + // Add a small delay to allow fade out animation to complete + setTimeout(() => { + setIsRecipientInBeneficiaries(true); + setIsOnrampModalOpen(false); + }, 300); + }, 2000); + }; + + const handleAddToBeneficiariesChange = async (checked: boolean) => { + setAddToBeneficiaries(checked); + if (checked) { + if (isOnramp) { + setIsOnrampModalOpen(true); + return; + } + await addBeneficiary(recipientName); + } else { + await removeRecipient(); + } + }; + + const handleOnrampModalClose = () => { + setIsOnrampModalOpen(false); + setAddToBeneficiaries(false); + }; + + const handleOnrampModalSave = async (name: string) => { + await addBeneficiary(name); + setIsOnrampModalOpen(false); + }; + + const addBeneficiary = async (name: string) => { + setIsSavingRecipient(true); + + const newRecipient = buildRecipient(name); + if (!newRecipient) { + setIsSavingRecipient(false); + return; + } + + // Save recipient via API + const accessToken = await getAccessToken(); + if (!accessToken) { + setIsSavingRecipient(false); return; } + try { + const success = await saveRecipient(newRecipient, accessToken); + if (success) { + handleSaveSuccess(); + } else { + setIsSavingRecipient(false); + } + } catch (error) { + console.error("Error saving recipient:", error); + setIsSavingRecipient(false); + } + }; + + const removeRecipient = async () => { try { const accessToken = await getAccessToken(); if (!accessToken) { @@ -610,11 +692,7 @@ export function TransactionStatus({ // Fetch saved recipients to find the recipient ID const savedRecipients = await fetchSavedRecipients(accessToken); - const recipientToDelete = savedRecipients.find( - (r) => - r.accountIdentifier === accountIdentifier && - r.institutionCode === institutionCode, - ); + const recipientToDelete = findRecipientInSaved(savedRecipients); if (!recipientToDelete) { console.error("Recipient not found in saved recipients"); @@ -629,7 +707,6 @@ export function TransactionStatus({ if (success) { // Update state to show the checkbox again since recipient is now removed setIsRecipientInBeneficiaries(false); - console.log("Recipient removed successfully"); } } catch (error) { console.error("Error removing recipient:", error); @@ -637,13 +714,42 @@ export function TransactionStatus({ }; const getPaymentMessage = () => { - const formattedRecipientName = recipientName - ? recipientName - .toLowerCase() - .split(" ") - .map((name) => name.charAt(0).toUpperCase() + name.slice(1)) - .join(" ") - : ""; + if (isOnramp) { + if (transactionStatus === "refunded") { + return ( + <> + Your payment to {recipientDisplayName} was unsuccessful. +
+
+ The funds will be refunded to your account. + + ); + } + + if (!["validated", "settled"].includes(transactionStatus)) { + return ( + <> + Processing payment to{" "} + + {recipientDisplayName} + + . Hang on, this will only take a few seconds + + ); + } + + return ( + <> + + {formatNumberWithCommas(amountReceived)} {token} + {" "} + has been successfully deposited into recipient wallet address{" "} + {recipientDisplayName} on {selectedNetwork.chain.name} network. + + ); + } + + const formattedRecipientName = recipientDisplayName; if (transactionStatus === "refunded") { return ( @@ -651,7 +757,8 @@ export function TransactionStatus({ Your transfer of{" "} {formatNumberWithCommas(amount)} {token} ( - {formatCurrency(fiat ?? 0, currency, `en-${currency.slice(0, 2)}`)}) + {getCurrencySymbol(currency)}{" "} + {formatNumberWithCommas(amountReceived ?? 0)}) {" "} to {formattedRecipientName} was unsuccessful.
@@ -667,7 +774,8 @@ export function TransactionStatus({ Processing payment of{" "} {formatNumberWithCommas(amount)} {token} ( - {formatCurrency(fiat ?? 0, currency, `en-${currency.slice(0, 2)}`)}) + {getCurrencySymbol(currency)}{" "} + {formatNumberWithCommas(amountReceived ?? 0)}) {" "} to {formattedRecipientName}. Hang on, this will only take a few seconds @@ -680,7 +788,8 @@ export function TransactionStatus({ Your transfer of{" "} {formatNumberWithCommas(amount)} {token} ( - {formatCurrency(fiat ?? 0, currency, `en-${currency.slice(0, 2)}`)}) + {getCurrencySymbol(currency)}{" "} + {formatNumberWithCommas(amountReceived ?? 0)}) {" "} to {formattedRecipientName} has been completed successfully. @@ -746,7 +855,7 @@ export function TransactionStatus({ /> )}

- {formatNumberWithCommas(amount)} {token} + {primaryAmountDisplay}

- {(recipientName ?? "").toLowerCase().split(" ")[0]} + {recipientDisplayName} @@ -775,9 +884,13 @@ export function TransactionStatus({ className="text-xl font-medium text-neutral-900 dark:text-white/80" > {transactionStatus === "refunded" - ? "Oops! Transaction failed" + ? isOnramp + ? "Oops! Deposit failed" + : "Oops! Transaction failed" : !["validated", "settled"].includes(transactionStatus) - ? "Processing payment..." + ? isOnramp + ? "Indexing by aggregator..." + : "Processing payment..." : "Transaction successful"} @@ -796,7 +909,7 @@ export function TransactionStatus({ /> )}

- {amount} {token} + {primaryAmountDisplay}

- {(recipientName ?? "").toLowerCase().split(" ")[0]} + {recipientDisplayName} @@ -847,33 +960,34 @@ export function TransactionStatus({ orderDetails, orderId, ) && ( - - - - )} + + + + )} - {["validated", "settled"].includes(transactionStatus) && ( - - )} + {["validated", "settled"].includes(transactionStatus) && + !isOnramp && ( + + )}