From 59b87fe471fff8d62a0cc249a4987d81a995b9cd Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Tue, 11 Nov 2025 10:18:57 +0100 Subject: [PATCH 01/22] feat: add Smile ID KYC submission functionality and integrate smart camera component in KycModal --- app/api/aggregator.ts | 54 ++++++- app/api/kyc/smile-id/route.ts | 142 ++++++++++++++++++ app/components/KycModal.tsx | 270 ++++++++++++++++++++++------------ app/hooks/useSwapButton.ts | 6 +- package.json | 2 + 5 files changed, 378 insertions(+), 96 deletions(-) create mode 100644 app/api/kyc/smile-id/route.ts diff --git a/app/api/aggregator.ts b/app/api/aggregator.ts index 4627e3e8..c4815483 100644 --- a/app/api/aggregator.ts +++ b/app/api/aggregator.ts @@ -669,4 +669,56 @@ export async function migrateLocalStorageRecipients( console.error("Error migrating recipients:", error); // Don't throw - let the app continue even if migration fails } -} +}; + +/** + * Submits Smile ID captured data for KYC verification + * @param {object} payload - The Smile ID data payload + * @returns {Promise} The submission response + * @throws {Error} If the API request fails + */ +export const submitSmileIDData = async ( + payload: any, +): Promise => { + const startTime = Date.now(); + + try { + // Track external API request + trackServerEvent("External API Request", { + service: "next-api", + endpoint: "/api/kyc/smile-id", + method: "POST", + }); + + console.log("Submitting Smile ID data payload:", payload); + + // Call Next.js API route instead of backend directly + const response = await axios.post(`/api/kyc/smile-id`, payload); + + // Track successful response + const responseTime = Date.now() - startTime; + trackApiResponse("/api/kyc/smile-id", "POST", 200, responseTime, { + service: "next-api", + }); + + // Track business event + trackBusinessEvent("Smile ID Data Submitted", { + jobId: response.data.data?.jobId, + }); + + return response.data; + } catch (error) { + const responseTime = Date.now() - startTime; + + // Track API error + trackServerEvent("External API Error", { + service: "next-api", + endpoint: "/api/kyc/smile-id", + method: "POST", + error_message: error instanceof Error ? error.message : "Unknown error", + response_time_ms: responseTime, + }); + + throw error; + } +}; \ No newline at end of file diff --git a/app/api/kyc/smile-id/route.ts b/app/api/kyc/smile-id/route.ts new file mode 100644 index 00000000..f6582692 --- /dev/null +++ b/app/api/kyc/smile-id/route.ts @@ -0,0 +1,142 @@ +import { NextRequest, NextResponse } from "next/server"; +import SIDCore from "smile-identity-core"; + +const SIDSignature = SIDCore.Signature; +const SIDWebAPI = SIDCore.WebApi; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { images, partner_params, walletAddress, signature, nonce } = body; + + // Validate required fields + if (!images || !Array.isArray(images) || images.length === 0) { + return NextResponse.json( + { status: "error", message: "Invalid images data" }, + { status: 400 } + ); + } + + if (!walletAddress || !signature || !nonce) { + return NextResponse.json( + { status: "error", message: "Missing wallet credentials" }, + { status: 400 } + ); + } + + // Initialize SmileID Web API + const connection = new SIDWebAPI( + process.env.SMILE_ID_PARTNER_ID!, + process.env.SMILE_ID_CALLBACK_URL || "", // Optional callback URL + process.env.SMILE_ID_API_KEY!, + process.env.SMILE_ID_SERVER!, // e.g., "https://3eydmgh10d.execute-api.us-west-2.amazonaws.com/test" + ); + + // Generate unique IDs + const timestamp = Date.now(); + const job_id = `job-${timestamp}-${walletAddress.slice(0, 8)}`; + const user_id = `user-${walletAddress}`; + + // Prepare partner params for SmileID + const smileIdPartnerParams = { + user_id, + job_id, + job_type: 1, // 1 for biometric KYC with ID verification + ...partner_params, + }; + + console.log("Submitting to SmileID:", { + user_id, + job_id, + images_count: images.length, + }); + + // Submit to SmileID + const options = { + return_job_status: true, + }; + + const smileIdResult = await connection.submit_job( + smileIdPartnerParams, + images, + {}, // id_info (optional) + options, + ); + + console.log("SmileID response:", smileIdResult); + + // Check if submission was successful + if (!smileIdResult || !smileIdResult.job_complete) { + return NextResponse.json( + { + status: "error", + message: "SmileID submission incomplete", + data: smileIdResult, + }, + { status: 500 } + ); + } + + // Now send the reference to your backend + const backendPayload = { + walletAddress, + signature, + nonce, + smileIdJobId: job_id, + smileIdUserId: user_id, + timestamp, + }; + + console.log("Sending reference to backend:", backendPayload); + + // Send to your aggregator backend + const backendResponse = await fetch( + `${process.env.NEXT_PUBLIC_AGGREGATOR_URL}/kyc/verify`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(backendPayload), + } + ); + + if (!backendResponse.ok) { + const errorData = await backendResponse.json(); + console.error("Backend error:", errorData); + return NextResponse.json( + { + status: "error", + message: "Failed to store KYC reference", + data: errorData, + }, + { status: 500 } + ); + } + + const backendData = await backendResponse.json(); + + // Return success response + return NextResponse.json({ + status: "success", + message: "KYC verification submitted successfully", + data: { + jobId: job_id, + userId: user_id, + smileIdResponse: smileIdResult, + backendResponse: backendData, + }, + }); + } catch (error) { + console.error("Error in SmileID submission:", error); + + return NextResponse.json( + { + status: "error", + message: error instanceof Error ? error.message : "Unknown error", + error: error instanceof Error ? error.stack : undefined, + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/components/KycModal.tsx b/app/components/KycModal.tsx index ac76f03a..d1089f6a 100644 --- a/app/components/KycModal.tsx +++ b/app/components/KycModal.tsx @@ -5,18 +5,31 @@ import { QRCode } from "react-qrcode-logo"; import { usePrivy, useWallets } from "@privy-io/react-auth"; import { FiExternalLink } from "react-icons/fi"; import { motion, AnimatePresence } from "framer-motion"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; +import "@smileid/web-components/smart-camera-web"; + +declare global { + namespace JSX { + interface IntrinsicElements { + "smart-camera-web": any; + } + } +} + import { CheckIcon, - QrCodeIcon, SadFaceIcon, UserDetailsIcon, VerificationPendingIcon, } from "./ImageAssets"; import { fadeInOut } from "./AnimatedComponents"; import { generateTimeBasedNonce } from "../utils"; -import { fetchKYCStatus, initiateKYC } from "../api/aggregator"; +import { + fetchKYCStatus, + initiateKYC, + submitSmileIDData, +} from "../api/aggregator"; import { primaryBtnClasses, secondaryBtnClasses } from "./Styles"; import { trackEvent } from "../hooks/analytics/client"; import { Cancel01Icon, CheckmarkCircle01Icon } from "hugeicons-react"; @@ -24,6 +37,7 @@ import { useInjectedWallet } from "../context"; export const STEPS = { TERMS: "terms", + CAPTURE: "capture", STATUS: { PENDING: "pending", SUCCESS: "success", @@ -36,6 +50,7 @@ export const STEPS = { type Step = | typeof STEPS.TERMS + | typeof STEPS.CAPTURE | typeof STEPS.LOADING | typeof STEPS.REFRESH | (typeof STEPS.STATUS)[keyof typeof STEPS.STATUS]; @@ -65,6 +80,10 @@ export const KycModal = ({ const [termsAccepted, setTermsAccepted] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [isSigning, setIsSigning] = useState(false); + const [isCapturing, setIsCapturing] = useState(false); + const [kycSignature, setKycSignature] = useState(""); + const [kycNonce, setKycNonce] = useState(""); + const [cameraElement, setCameraElement] = useState(null); const handleSignAndContinue = async () => { setIsSigning(true); @@ -114,25 +133,11 @@ export const KycModal = ({ ? signature.slice(2) : signature; - const response = await initiateKYC({ - signature: sigWithoutPrefix, - walletAddress: walletAddress || "", - nonce, - }); + setKycSignature(sigWithoutPrefix); + setKycNonce(nonce); - if (response.status === "success") { - trackEvent("Account verification", { - "Verification status": "Pending", - }); - setKycUrl(response.data.url); - setShowQRCode(true); - setStep(STEPS.STATUS.PENDING); - } else { - setStep(STEPS.STATUS.FAILED); - trackEvent("Account verification", { - "Verification status": "Failed", - }); - } + // Skip old KYC initiation since we're using Smile ID + setStep(STEPS.CAPTURE); } } catch (error: unknown) { console.log("error", error); @@ -155,30 +160,6 @@ export const KycModal = ({ } }; - const QRCodeComponent = useCallback( - () => ( -
- -
- ), - [kycUrl], - ); const renderTerms = () => ( @@ -316,46 +297,49 @@ export const KycModal = ({ ); - const renderQRCode = () => ( - -
-
-

- Verify with your phone or URL -

- + const renderCapture = () => ( + +
+ +
+

+ Capture your documents +

+

+ Please take a selfie and capture your ID document for verification. +

+
-

- Scan with your phone to have the best verification experience. You can - also open the URL below -

- - +
+ {/* @ts-ignore */} + { + console.log("๐Ÿ”— Ref callback called with:", el); + setCameraElement(el); + }} + theme-color="#8B85F4" + capture-id + > +
-
-
-

Or

-
+ {isCapturing && ( +
+ Processing your verification...
+ )} ); + const renderPendingStatus = () => ( @@ -372,14 +356,6 @@ export const KycModal = ({
- + + {isCountryDropdownOpen && ( +
+
+ setCountrySearch(e.target.value)} + className="w-full px-3 py-2 text-sm bg-transparent border border-border-input dark:border-white/20 rounded-lg focus:outline-none focus:border-gray-400 dark:focus:border-white/40 text-neutral-900 dark:text-white/80 placeholder:text-text-placeholder dark:placeholder:text-white/30" + autoFocus + /> +
+ +
+ {isLoadingCountries ? ( +
+
Loading countries...
+
+ ) : filteredCountries.length > 0 ? ( + filteredCountries.map((country) => ( + + )) + ) : ( +
+
No countries found
+
+ )} +
+
+ )} +
+ + setPhoneNumber(e.target.value)} + placeholder="enter your phone number" + className="min-h-12 w-full rounded-xl border border-border-input bg-transparent py-3 pl-24 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-black2 dark:placeholder:text-white/30 dark:focus-within:border-white/40 text-neutral-900 dark:text-white/80" + style={{ paddingLeft: `${selectedCountry.code.length * 8 + 60}px` }} + /> +
+ + + + {/* Info message */} +
+ +

+ With just your phone number, you can swap up to $100/month. Verify your ID later to unlock even higher limits. +

+
+ + +
+ ); + + const renderEnterOtp = () => ( + +
+ setStep(STEPS.ENTER_PHONE)} /> + + Enter the code we texted you + +

+ We sent a 6-digit code to {formattedPhone.replace(/(\+\d+\s+\d{3})[\s\d]+(\d{2})/, '$1**$2')} to verify your number. +

+
+ + {/* OTP Input */} +
+ +
+ {[...Array(6)].map((_, index) => ( + { + const value = e.target.value.replace(/\D/g, ''); + if (value.length <= 1) { + const newOtp = otpCode.split(''); + newOtp[index] = value; + setOtpCode(newOtp.join('')); + + if (value && index < 5) { + const nextInput = e.target.parentElement?.children[index + 1] as HTMLInputElement; + nextInput?.focus(); + } + } + }} + onKeyDown={(e) => { + // Handle backspace to move to previous input + if (e.key === 'Backspace' && !otpCode[index] && index > 0) { + const prevInput = (e.target as HTMLInputElement).parentElement?.children[index - 1] as HTMLInputElement; + prevInput?.focus(); + } + }} + className="h-[48px] w-[44px] rounded-2xl bg-transparent dark:bg-surface-overlay text-center text-lg font-medium transition-all focus-within:border-lavender-600 focus:outline-none dark:focus-within:border dark:focus-within:border-lavender-600 text-neutral-900 dark:text-lavender-600" + /> + ))} +
+ {attemptsRemaining < 3 && ( + + {attemptsRemaining} attempts remaining + + )} + +
+ Didn't receive a code?{' '} + +
+
+ +
+ + +
+
+ ); + + const renderVerified = () => ( + + + + +
+

+ Phone number verification successful! +

+

+ You can now start converting your crypto to fiats at zero fees on noblocks +

+
+ + +
+ ); + + return ( + + + ); +} \ No newline at end of file diff --git a/app/components/ProfileDrawer.tsx b/app/components/ProfileDrawer.tsx new file mode 100644 index 00000000..bd542b46 --- /dev/null +++ b/app/components/ProfileDrawer.tsx @@ -0,0 +1,375 @@ +"use client"; +import { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { toast } from 'sonner'; +import { Dialog } from "@headlessui/react"; +import { + ArrowRight03Icon, + ArrowDown01Icon, + Copy01Icon, + StarIcon, + InformationCircleIcon, + FaceIdIcon, + CallingIcon +} from 'hugeicons-react'; +import { usePrivy, useLinkAccount } from "@privy-io/react-auth"; +import { useKYCStatus, KYC_TIERS } from '../hooks/useKYCStatus'; +import { formatNumberWithCommas, shortenAddress, classNames } from '../utils'; +import { sidebarAnimation } from './AnimatedComponents'; +import { PiCheck } from "react-icons/pi"; +import { TbIdBadge } from 'react-icons/tb'; +import TransactionLimitModal from './TransactionLimitModal'; + +interface ProfileDrawerProps { + isOpen: boolean; + onClose: () => void; +} + +export default function ProfileDrawer({ isOpen, onClose }: ProfileDrawerProps) { + const { user } = usePrivy(); + const { + tier, + transactionSummary, + getCurrentLimits, + refreshStatus + } = useKYCStatus(); + + const [isLimitModalOpen, setIsLimitModalOpen] = useState(false); + const [expandedTiers, setExpandedTiers] = useState>({}); + const [isAddressCopied, setIsAddressCopied] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const currentLimits = getCurrentLimits(); + const monthlyProgress = (transactionSummary.monthlySpent / currentLimits.monthly) * 100; + + // Refresh KYC status only if last refresh was >30s ago + const lastRefreshRef = useRef(0); + useEffect(() => { + if (isOpen) { + const now = Date.now(); + if (now - lastRefreshRef.current > 30000) { + setIsLoading(true); + refreshStatus().finally(() => setIsLoading(false)); + lastRefreshRef.current = now; + } + } + }, [isOpen, refreshStatus]); + + const walletAddress = user?.linkedAccounts.find((account) => account.type === "smart_wallet")?.address; + + const { linkEmail } = useLinkAccount({ + onSuccess: ({ user }) => { + toast.success(`${user.email} linked successfully`); + }, + onError: () => { + toast.error("Error linking account", { + description: "You might have this email linked already", + }); + }, + }); + + const handleCopyAddress = () => { + if (walletAddress) { + navigator.clipboard.writeText(walletAddress); + setIsAddressCopied(true); + toast.success("Address copied to clipboard"); + setTimeout(() => setIsAddressCopied(false), 2000); + } + }; + + const toggleTierExpansion = (tierLevel: number) => { + setExpandedTiers(prev => ({ + ...prev, + [tierLevel]: !prev[tierLevel] + })); + }; + + // Auto-expand next tier section + useEffect(() => { + if (tier < 1) { + setExpandedTiers(prev => ({ + ...prev, + [tier + 1]: true + })); + } + }, [tier]); + + const renderSkeletonLoader = () => ( +
+ {/* Account card skeleton */} +
+
+
+
+
+
+
+
+
+
+ + {/* Current tier skeleton */} +
+
+
+
+
+
+
+
+
+ + {/* Tier sections skeleton */} + {[1, 2].map((i) => ( +
+
+
+ ))} +
+ ); + + const renderTierSection = (tierLevel: number) => { + const tierData = KYC_TIERS[tierLevel]; + const isExpanded = expandedTiers[tierLevel]; + + if (!tierData) return null; + + return ( +
+ + + + {isExpanded && ( + +
+
    + {tierData.requirements.map((req, index) => ( +
  • +
    + {req.includes('number') ? ( + + ) : req.includes('verification') ? ( + + ) : req.includes('ID') && ( + + )} +
    + {req} +
  • + ))} +
+
+

Limit

+

${formatNumberWithCommas(tierData.limits.monthly)} / month

+
+ {tier == 0 && tierLevel === tier + 1 && ( + + )} +
+
+ )} +
+
+ ); + }; + + return ( + <> + + {isOpen && ( + +
+ {/* Backdrop overlay */} + + + {/* Drawer content */} + +
+ {/* Header with close button */} +
+

+ Profile +

+ +
+ +
+ {isLoading ? renderSkeletonLoader() : ( + <> + {/* Account info card */} +
+

Account

+ + {/* Email Connection */} +
+ +
+ {user?.email ? ( +
+

+ {user.email.address} +

+
+ ) : ( + + )} + + {/* Wallet Address */} +
+

+ {shortenAddress(walletAddress ?? "", 10)} +

+ +
+
+
+
+ + + {/* Current Tier Status */} + {tier >= 1 && tier !== undefined && ( +
+ {/* Current Tier Badge */} + +
+ + + Current: {KYC_TIERS[tier]?.name || 'Tier 0'} + +
+ + {/* Monthly Limit Progress */} +
+
+ + Monthly limit + + +
+ +
+ ${formatNumberWithCommas(transactionSummary.monthlySpent)} / ${formatNumberWithCommas(currentLimits.monthly)} +
+ + {/* Progress Bar */} +
+
+
+
+ + {/* Upgrade Button */} + {tier < 2 && ( + + )} +
+ )} + + {/* Tier Information */} + + {Object.values(KYC_TIERS) + .filter(tierData => tierData.level > tier) // Only show tiers above current + .map(tierData =>
{renderTierSection(tierData.level)}
)} + + )} +
+
+ +
+
+ )} +
+ + { + setIsLimitModalOpen(false); + await refreshStatus(); + }} + /> + + ); +} \ No newline at end of file diff --git a/app/components/SettingsDropdown.tsx b/app/components/SettingsDropdown.tsx index 3813b232..c2e53318 100644 --- a/app/components/SettingsDropdown.tsx +++ b/app/components/SettingsDropdown.tsx @@ -19,11 +19,13 @@ import { Setting07Icon, Wallet01Icon, Key01Icon, + FaceIdIcon, } from "hugeicons-react"; import { toast } from "sonner"; import { useInjectedWallet } from "../context"; import { useWalletDisconnect } from "../hooks/useWalletDisconnect"; import { CopyAddressWarningModal } from "./CopyAddressWarningModal"; +import ProfileDrawer from "./ProfileDrawer"; export const SettingsDropdown = () => { const { user, updateEmail } = usePrivy(); @@ -34,6 +36,7 @@ export const SettingsDropdown = () => { const [isOpen, setIsOpen] = useState(false); const [isAddressCopied, setIsAddressCopied] = useState(false); const [isWarningModalOpen, setIsWarningModalOpen] = useState(false); + const [isProfileDrawerOpen, setIsProfileDrawerOpen] = useState(false); const dropdownRef = useRef(null); useOutsideClick({ @@ -267,6 +270,22 @@ export const SettingsDropdown = () => {

Export wallet

)} */} +
  • + +
  • {!isInjectedWallet && (
  • { onClose={() => setIsWarningModalOpen(false)} address={walletAddress ?? ""} /> + + setIsProfileDrawerOpen(false)} + /> ); }; diff --git a/app/components/TransactionLimitModal.tsx b/app/components/TransactionLimitModal.tsx new file mode 100644 index 00000000..4536dee0 --- /dev/null +++ b/app/components/TransactionLimitModal.tsx @@ -0,0 +1,221 @@ +"use client"; +import { useEffect, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { + InformationSquareIcon, + Wallet02Icon, + StarIcon, + InformationCircleIcon +} from 'hugeicons-react'; +import { useKYCStatus, KYC_TIERS } from '../hooks/useKYCStatus'; +import { usePhoneVerification } from '../hooks/usePhoneVerification'; +import PhoneVerificationModal from './PhoneVerificationModal'; +import { primaryBtnClasses, secondaryBtnClasses } from './Styles'; +import { AnimatedModal, fadeInOut } from './AnimatedComponents'; +import { formatNumberWithCommas } from '../utils'; +import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react'; +import { KycModal } from './KycModal'; + +interface TransactionLimitModalProps { + isOpen: boolean; + onClose: () => void; + transactionAmount?: number; +} + +export default function TransactionLimitModal({ + isOpen, + onClose, + transactionAmount = 0 +}: TransactionLimitModalProps) { + const { + tier, + isPhoneVerified, + isFullyVerified, + getCurrentLimits, + getRemainingLimits, + canTransact, + refreshStatus, + transactionSummary + } = useKYCStatus(); + + const { refreshStatus: refreshPhoneStatus } = usePhoneVerification(); + const [isPhoneModalOpen, setIsPhoneModalOpen] = useState(false); + const [isKycModalOpen, setIsKycModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // Refresh KYC status every time modal is opened + useEffect(() => { + if (isOpen) { + setIsLoading(true); + refreshStatus().finally(() => setIsLoading(false)); + } + }, [isOpen, refreshStatus]); + + // Auto-open phone verification modal if tier is less than 1 (unverified) + useEffect(() => { + if (isOpen && !isLoading && (tier < 1 || tier === undefined)) { + setIsPhoneModalOpen(true); + } + }, [isOpen, isLoading, tier]); + + const currentLimits = getCurrentLimits(); + const remaining = getRemainingLimits(); + const currentTier = KYC_TIERS[tier]; + const nextTier = KYC_TIERS[tier + 1]; + const transactionCheck = canTransact(transactionAmount); + + const handlePhoneVerified = async (phoneNumber: string) => { + await Promise.all([ + refreshStatus(), + refreshPhoneStatus() + ]); + setIsPhoneModalOpen(false); + // Close the transaction limit modal after successful verification + onClose(); + }; + + const renderLoadingStatus = () => ( + +
    +
    + ); + + const renderMainContent = () => ( + +
    + + + Increase your transaction limit + +

    + Your current monthly limit is ${formatNumberWithCommas(currentLimits.monthly)}. Verify your identity to unlock higher limits. +

    +
    + +
    + + +
    + + + Current: {tier < 1 ? 'Tier 0' : currentTier?.name || 'Tier 0'} + +
    + + {/* Monthly Limit Progress */} +
    +
    + + Monthly limit + + +
    + + {/* Progress Display */} +
    +
    + ${formatNumberWithCommas(transactionSummary.monthlySpent)} / ${formatNumberWithCommas(currentLimits.monthly)} +
    + + {/* Progress Bar */} +
    +
    +
    +
    +
    + + +
    + {/* Info Text */} +
    + +

    + {tier < 1 ? ( + <>Tier 0 gives you ${formatNumberWithCommas(currentLimits.monthly)}/month. Verify your ID to unlock ${nextTier ? formatNumberWithCommas(nextTier.limits.monthly) : '1,000'}/month and beyond. Learn more. + ) : ( + <>You're currently at {currentTier?.name} with ${formatNumberWithCommas(currentLimits.monthly)}/month. {nextTier ? `Upgrade to ${nextTier.name} for ${formatNumberWithCommas(nextTier.limits.monthly)}/month` : 'You have the highest tier available'}. Learn more. + )} +

    +
    + + {/* Action Button */} + {tier < 2 && ( + + )} + + {/* Already at max tier */} + {tier >= 2 && ( + + )} + + ); + + if (!isOpen) return null; + + return ( + <> + + {isOpen && !isPhoneModalOpen && ( + + + )} + + + { + setIsPhoneModalOpen(false); + onClose(); // Close parent modal too + }} + onVerified={handlePhoneVerified} + /> + + + {isKycModalOpen && ( + { setIsKycModalOpen(false); onClose(); }} + > + { }} + /> + + )} + + + ); +} diff --git a/app/components/index.ts b/app/components/index.ts index fcb264eb..620a63e1 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -27,6 +27,8 @@ export { KycModal } from "./KycModal"; export { CookieConsent } from "./CookieConsent"; export { NetworkSelectionModal } from "./NetworkSelectionModal"; export { Disclaimer } from "./Disclaimer"; +export { default as PhoneVerificationModal } from "./PhoneVerificationModal"; +export { default as TransactionLimitModal } from "./TransactionLimitModal"; export { TransactionForm } from "../pages/TransactionForm"; export { TransactionPreview } from "../pages/TransactionPreview"; diff --git a/app/hooks/useKYCStatus.ts b/app/hooks/useKYCStatus.ts new file mode 100644 index 00000000..b38155e3 --- /dev/null +++ b/app/hooks/useKYCStatus.ts @@ -0,0 +1,187 @@ +// Extend the Window interface for our in-memory fetch guard +declare global { + interface Window { + __KYC_FETCH_GUARDS__?: Record; + } +} +import { useState, useEffect, useCallback } from 'react'; +import { useWallets } from '@privy-io/react-auth'; + +export interface TransactionLimits { + monthly: number; +} + +export interface KYCTier { + level: 0 | 1 | 2; + name: string; + limits: TransactionLimits; + requirements: string[]; +} + +export const KYC_TIERS: Record = { +// 0: { +// level: 0, +// name: 'Tier 0', +// limits: { +// monthly: 0, +// }, +// requirements: ['Wallet connection only'] +// }, + 1: { + level: 1, + name: 'Tier 1', + limits: { + monthly: 100, + }, + requirements: ['Phone number'] + }, + 2: { + level: 2, + name: 'Tier 2', + limits: { + monthly: 15000, + }, + requirements: ['Government ID', 'Selfie verification'] + } +}; + +interface UserTransactionSummary { + dailySpent: number; + monthlySpent: number; + lastTransactionDate: string | null; +} + +interface KYCStatus { + tier: 0 | 1 | 2; + isPhoneVerified: boolean; + phoneNumber: string | null; + isFullyVerified: boolean; + transactionSummary: UserTransactionSummary; + canTransact: (amount: number) => { allowed: boolean; reason?: string }; + getCurrentLimits: () => TransactionLimits; + getRemainingLimits: () => TransactionLimits; + refreshStatus: () => Promise; +} + +export function useKYCStatus(): KYCStatus { + + const { wallets } = useWallets(); + const embeddedWallet = wallets.find( + (wallet) => wallet.walletClientType === "privy", + ); + const walletAddress = embeddedWallet?.address; + + // In-memory guards for fetches per wallet + const fetchGuards = (typeof window !== 'undefined') ? (window.__KYC_FETCH_GUARDS__ = window.__KYC_FETCH_GUARDS__ || {}) : {}; + const guardKey = walletAddress || 'no_wallet'; + + const [tier, setTier] = useState<0 | 1 | 2>(0); + const [isPhoneVerified, setIsPhoneVerified] = useState(false); + const [phoneNumber, setPhoneNumber] = useState(null); + const [isFullyVerified, setIsFullyVerified] = useState(false); + const [transactionSummary, setTransactionSummary] = useState({ + dailySpent: 0, + monthlySpent: 0, + lastTransactionDate: null, + }); + + const getCurrentLimits = useCallback((): TransactionLimits => { + if (tier > 0){ + return KYC_TIERS[tier].limits; + } else { + return { monthly: 0 }; + } + }, [tier]); + + const getRemainingLimits = useCallback((): TransactionLimits => { + const currentLimits = getCurrentLimits(); + return { + monthly: Math.max(0, currentLimits.monthly - transactionSummary.monthlySpent), + }; + }, [getCurrentLimits, transactionSummary]); + + const canTransact = useCallback((amount: number): { allowed: boolean; reason?: string } => { + const remaining = getRemainingLimits(); + + + if (amount > remaining.monthly) { + return { + allowed: false, + reason: `Transaction amount ($${amount}) exceeds remaining monthly limit ($${remaining.monthly})` + }; + } + + return { allowed: true }; + }, [getCurrentLimits, getRemainingLimits, tier]); + + + const fetchTransactionSummary = useCallback(async () => { + if (!walletAddress) return; + if (fetchGuards[`${guardKey}_tx`] === 'fetching') return; + fetchGuards[`${guardKey}_tx`] = 'fetching'; + try { + const response = await fetch(`/api/kyc/transaction-summary?walletAddress=${encodeURIComponent(walletAddress)}`); + const data = await response.json(); + if (data.success) { + setTransactionSummary({ + dailySpent: data.dailySpent, + monthlySpent: data.monthlySpent, + lastTransactionDate: data.lastTransactionDate, + }); + } else { + console.error('Error fetching transaction summary:', data.error); + } + } catch (error) { + console.error('Error calculating transaction summary:', error); + } finally { + fetchGuards[`${guardKey}_tx`] = 'done'; + } + }, [walletAddress]); + + + const fetchKYCStatus = useCallback(async () => { + if (!walletAddress) return; + if (fetchGuards[`${guardKey}_kyc`] === 'fetching') return; + fetchGuards[`${guardKey}_kyc`] = 'fetching'; + try { + const response = await fetch(`/api/kyc/status?walletAddress=${encodeURIComponent(walletAddress)}`); + const data = await response.json(); + if (data.success) { + setTier(data.tier); + setIsPhoneVerified(data.isPhoneVerified); + setPhoneNumber(data.phoneNumber); + setIsFullyVerified(data.isFullyVerified); + } else { + console.error('Error fetching KYC status:', data.error); + } + } catch (error) { + console.error('Error fetching KYC status:', error); + } finally { + fetchGuards[`${guardKey}_kyc`] = 'done'; + } + }, [walletAddress]); + + const refreshStatus = useCallback(async () => { + await Promise.all([ + fetchKYCStatus(), + fetchTransactionSummary() + ]); + }, [fetchKYCStatus, fetchTransactionSummary]); + + // Initial load and wallet address change + useEffect(() => { + refreshStatus(); + }, [refreshStatus]); + + return { + tier, + isPhoneVerified, + phoneNumber, + isFullyVerified, + transactionSummary, + canTransact, + getCurrentLimits, + getRemainingLimits, + refreshStatus, + }; +} \ No newline at end of file diff --git a/app/hooks/usePhoneVerification.ts b/app/hooks/usePhoneVerification.ts new file mode 100644 index 00000000..a4f4398c --- /dev/null +++ b/app/hooks/usePhoneVerification.ts @@ -0,0 +1,106 @@ +// Extend the Window interface for our in-memory phone fetch guard +declare global { + interface Window { + __PHONE_FETCH_GUARDS__?: Record; + } +} +import { useState, useEffect, useCallback } from 'react'; +import { useWallets } from '@privy-io/react-auth'; + +interface PhoneVerificationStatus { + isVerified: boolean; + phoneNumber: string | null; + verifiedAt: string | null; + provider: 'termii' | 'twilio' | null; + isLoading: boolean; + error: string | null; +} + +export function usePhoneVerification() { + + const { wallets } = useWallets(); + const embeddedWallet = wallets.find( + (wallet) => wallet.walletClientType === "privy", + ); + const walletAddress = embeddedWallet?.address; + + // In-memory guards for fetches per wallet + const fetchGuards = (typeof window !== 'undefined') ? (window.__PHONE_FETCH_GUARDS__ = window.__PHONE_FETCH_GUARDS__ || {}) : {}; + const guardKey = walletAddress || 'no_wallet'; + + const [status, setStatus] = useState({ + isVerified: false, + phoneNumber: null, + verifiedAt: null, + provider: null, + isLoading: false, + error: null, + }); + + const checkVerificationStatus = useCallback(async () => { + if (!walletAddress) { + setStatus(prev => ({ + ...prev, + isVerified: false, + phoneNumber: null, + verifiedAt: null, + provider: null, + isLoading: false, + })); + return; + } + if (fetchGuards[`${guardKey}_phone`] === 'fetching') return; + fetchGuards[`${guardKey}_phone`] = 'fetching'; + setStatus(prev => ({ ...prev, isLoading: true, error: null })); + try { + const response = await fetch(`/api/phone/status?walletAddress=${encodeURIComponent(walletAddress)}`); + const data = await response.json(); + if (data.success) { + setStatus(prev => ({ + ...prev, + isVerified: data.verified, + phoneNumber: data.phoneNumber, + verifiedAt: data.verifiedAt, + provider: data.provider, + isLoading: false, + })); + } else { + setStatus(prev => ({ + ...prev, + error: data.error || 'Failed to check verification status', + isLoading: false, + })); + } + } catch (error) { + console.error('Error checking phone verification status:', error); + setStatus(prev => ({ + ...prev, + error: 'Failed to check verification status', + isLoading: false, + })); + } finally { + fetchGuards[`${guardKey}_phone`] = 'done'; + } + }, [walletAddress]); + + const markAsVerified = useCallback((phoneNumber: string, provider: 'termii' | 'twilio' = 'termii') => { + setStatus(prev => ({ + ...prev, + isVerified: true, + phoneNumber, + verifiedAt: new Date().toISOString(), + provider, + })); + }, []); + + // Check status when wallet address changes + useEffect(() => { + checkVerificationStatus(); + }, [checkVerificationStatus]); + + return { + ...status, + refreshStatus: checkVerificationStatus, + markAsVerified, + }; +} \ No newline at end of file diff --git a/app/hooks/useSwapButton.ts b/app/hooks/useSwapButton.ts index ad5a1395..5f77223a 100644 --- a/app/hooks/useSwapButton.ts +++ b/app/hooks/useSwapButton.ts @@ -102,7 +102,7 @@ export function useSwapButton({ handleSwap: () => void, login: () => void, handleFundWallet: () => void, - setIsKycModalOpen: () => void, + setIsLimitModalOpen: () => void, isUserVerified: boolean, ) => { if (!authenticated && !isInjectedWallet) { @@ -112,9 +112,9 @@ export function useSwapButton({ return handleFundWallet; } if (hasInsufficientBalance && !isUserVerified && (authenticated || isInjectedWallet)) { - return setIsKycModalOpen; + return setIsLimitModalOpen; } - // return handleSwap; + return handleSwap; }; return { diff --git a/app/lib/countries.ts b/app/lib/countries.ts new file mode 100644 index 00000000..e0220a4f --- /dev/null +++ b/app/lib/countries.ts @@ -0,0 +1,104 @@ +export interface Country { + code: string; + flag: string; // URL to flag image + name: string; + country: string; +} + +// Cache for countries data +let countriesCache: Country[] | null = null; + +/** + * Fetches country data from REST Countries API + * Returns countries with calling codes, flags, and names + */ +export async function fetchCountries(): Promise { + // Return cached data if available + if (countriesCache) { + return countriesCache; + } + + try { + const response = await fetch( + 'https://restcountries.com/v3.1/all?fields=name,cca2,idd,flag' + ); + + if (!response.ok) { + throw new Error('Failed to fetch countries'); + } + + const data = await response.json(); + + // Transform the API response to our format + const countries: Country[] = data + .filter((country: any) => { + // Only include countries with valid calling codes + return country.idd?.root && country.idd?.suffixes?.length > 0; + }) + .map((country: any) => { + // Get the first calling code (some countries have multiple) + const callingCode = country.idd.root + (country.idd.suffixes[0] || ''); + + return { + code: callingCode, + flag: `https://flagcdn.com/w40/${country.cca2.toLowerCase()}.png`, + name: country.name.common, + country: country.cca2 + }; + }) + .sort((a: Country, b: Country) => a.name.localeCompare(b.name)); // Sort alphabetically + + // Cache the results + countriesCache = countries; + return countries; + } catch (error) { + console.error('Error fetching countries:', error); + + // Fallback to a basic list if API fails + return getDefaultCountries(); + } +} + +/** + * Fallback country list in case the API is unavailable + */ +function getDefaultCountries(): Country[] { + return [ + { code: '+234', flag: 'https://flagcdn.com/w40/ng.png', name: 'Nigeria', country: 'NG' }, + { code: '+254', flag: 'https://flagcdn.com/w40/ke.png', name: 'Kenya', country: 'KE' }, + { code: '+233', flag: 'https://flagcdn.com/w40/gh.png', name: 'Ghana', country: 'GH' }, + { code: '+27', flag: 'https://flagcdn.com/w40/za.png', name: 'South Africa', country: 'ZA' }, + { code: '+1', flag: 'https://flagcdn.com/w40/us.png', name: 'United States', country: 'US' }, + { code: '+44', flag: 'https://flagcdn.com/w40/gb.png', name: 'United Kingdom', country: 'GB' }, + { code: '+33', flag: 'https://flagcdn.com/w40/fr.png', name: 'France', country: 'FR' }, + { code: '+49', flag: 'https://flagcdn.com/w40/de.png', name: 'Germany', country: 'DE' }, + { code: '+81', flag: 'https://flagcdn.com/w40/jp.png', name: 'Japan', country: 'JP' }, + { code: '+86', flag: 'https://flagcdn.com/w40/cn.png', name: 'China', country: 'CN' }, + { code: '+91', flag: 'https://flagcdn.com/w40/in.png', name: 'India', country: 'IN' }, + { code: '+61', flag: 'https://flagcdn.com/w40/au.png', name: 'Australia', country: 'AU' }, + { code: '+55', flag: 'https://flagcdn.com/w40/br.png', name: 'Brazil', country: 'BR' }, + { code: '+52', flag: 'https://flagcdn.com/w40/mx.png', name: 'Mexico', country: 'MX' }, + { code: '+7', flag: 'https://flagcdn.com/w40/ru.png', name: 'Russia', country: 'RU' } + ]; +} + +/** + * Get popular countries that should appear at the top of the list + */ +export function getPopularCountries(): string[] { + return ['NG', 'KE', 'GH', 'ZA', 'US', 'GB', 'CA', 'AU']; +} + +/** + * Search countries by name or calling code + */ +export function searchCountries(countries: Country[], query: string): Country[] { + if (!query.trim()) return countries; + + const searchTerm = query.toLowerCase(); + return countries.filter(country => + country.name.toLowerCase().includes(searchTerm) || + country.code.includes(searchTerm) || + country.country.toLowerCase().includes(searchTerm) + ); +} \ No newline at end of file diff --git a/app/lib/phone-verification.ts b/app/lib/phone-verification.ts new file mode 100644 index 00000000..c61bc9f2 --- /dev/null +++ b/app/lib/phone-verification.ts @@ -0,0 +1,151 @@ +import { parsePhoneNumber, CountryCode } from 'libphonenumber-js'; +import twilio from 'twilio'; + +// Initialize Twilio client +const twilioClient = twilio( + process.env.TWILIO_ACCOUNT_SID!, + process.env.TWILIO_AUTH_TOKEN! +); + +// African country codes that should use Termii +const AFRICAN_COUNTRIES: CountryCode[] = [ + 'NG', 'KE', 'GH', 'ZA', 'UG', 'TZ', 'EG', 'MA', 'DZ', 'AO', + 'MG', 'CM', 'CI', 'NE', 'BF', 'ML', 'MW', 'ZM', 'SN', 'SO', + 'TD', 'GN', 'RW', 'BJ', 'TN', 'BI', 'ER', 'SL', 'TG', 'LR', + 'LY', 'MR', 'GM', 'BW', 'GA', 'LS', 'GW', 'GQ', 'MU', + 'DJ', 'SZ', 'KM', 'CV', 'ST', 'SC', 'SS', 'CF', 'CD', 'CG' +]; + +export interface PhoneVerificationResult { + success: boolean; + message: string; + messageId?: string; + error?: string; +} + +export interface PhoneValidation { + isValid: boolean; + country?: CountryCode; + internationalFormat?: string; + isAfrican: boolean; + provider: 'termii' | 'twilio'; +} + +/** + * Validates and parses a phone number + */ +export function validatePhoneNumber(phoneNumber: string): PhoneValidation { + try { + const parsed = parsePhoneNumber(phoneNumber); + + if (!parsed || !parsed.isValid()) { + return { + isValid: false, + isAfrican: false, + provider: 'twilio' + }; + } + + const country = parsed.country as CountryCode; + const isAfrican = AFRICAN_COUNTRIES.includes(country); + + return { + isValid: true, + country, + internationalFormat: parsed.formatInternational(), + isAfrican, + provider: isAfrican ? 'termii' : 'twilio' + }; + } catch (error) { + console.error('Error validating phone number:', error); + return { + isValid: false, + isAfrican: false, + provider: 'twilio' + }; + } +} + +/** + * Sends OTP via Termii for African numbers + */ +export async function sendTermiiOTP( + phoneNumber: string, + code: string +): Promise { + try { + const response = await fetch('https://v3.api.termii.com/api/sms/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + to: phoneNumber, + from: process.env.TERMII_SENDER_ID || 'Noblocks', + sms: `Your Noblocks verification code is: ${code}. This code expires in 5 minutes.`, + type: 'plain', + channel: 'generic', + api_key: process.env.TERMII_API_KEY, + }), + }); + + const data = await response.json(); + + if (data.message === 'Successfully Sent') { + return { + success: true, + message: 'OTP sent successfully via Termii', + messageId: data.message_id, + }; + } else { + return { + success: false, + message: 'Failed to send OTP via Termii', + error: data.message || 'Unknown error', + }; + } + } catch (error) { + console.error('Termii OTP error:', error); + return { + success: false, + message: 'Failed to send OTP via Termii', + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +/** + * Sends OTP via Twilio for international numbers + */ +export async function sendTwilioOTP( + phoneNumber: string, + code: string +): Promise { + try { + const message = await twilioClient.messages.create({ + body: `Your Noblocks verification code is: ${code}. This code expires in 5 minutes.`, + from: process.env.TWILIO_PHONE_NUMBER, + to: phoneNumber, + }); + + return { + success: true, + message: 'OTP sent successfully via Twilio', + messageId: message.sid, + }; + } catch (error: any) { + console.error('Twilio OTP error:', error); + return { + success: false, + message: 'Failed to send OTP via Twilio', + error: error.message || 'Unknown error', + }; + } +} + +/** + * Generates a 6-digit OTP + */ +export function generateOTP(): string { + return Math.floor(100000 + Math.random() * 900000).toString(); +} \ No newline at end of file diff --git a/app/lib/smileID.ts b/app/lib/smileID.ts new file mode 100644 index 00000000..f557386c --- /dev/null +++ b/app/lib/smileID.ts @@ -0,0 +1,65 @@ +import SIDCore from "smile-identity-core"; + +const SIDSignature = SIDCore.Signature; +const SIDWebAPI = SIDCore.WebApi; + +export async function submitSmileIDJob({ images, partner_params, walletAddress, signature, nonce }: { + images: any[]; + partner_params: any; + walletAddress: string; + signature: string; + nonce: string; +}) { + // Validate required env vars + const partnerId = process.env.SMILE_IDENTITY_PARTNER_ID; + const callbackUrl = process.env.SMILE_ID_CALLBACK_URL || ""; + const apiKey = process.env.SMILE_IDENTITY_API_KEY; + const serverUrl = process.env.SMILE_IDENTITY_SERVER; + + if (!partnerId || !apiKey || !serverUrl) { + throw new Error("Missing SmileID environment variables"); + } + + // Initialize SmileID Web API + const connection = new SIDWebAPI( + partnerId, + callbackUrl, + apiKey, + serverUrl, + ); + + // Generate unique IDs + const timestamp = Date.now(); + const job_id = `job-${timestamp}-${walletAddress.slice(0, 8)}`; + const user_id = `user-${walletAddress}`; + + // Prepare partner params for SmileID + const smileIdPartnerParams = { + user_id, + job_id, + job_type: 4, // 4 for selfie enrollment (doesn't require country/ID info) + ...partner_params, + }; + + // Submit to SmileID + const options = { return_job_status: true }; + + try { + const smileIdResult = await connection.submit_job( + smileIdPartnerParams, + images, + {}, // id_info (optional) + options, + ); + + return { smileIdResult, job_id, user_id }; + } catch (error: any) { + console.error('SmileID API Error:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + message: error.message, + }); + throw error; + } +} diff --git a/app/lib/supabase.ts b/app/lib/supabase.ts index 211c8377..6931b0d8 100644 --- a/app/lib/supabase.ts +++ b/app/lib/supabase.ts @@ -1,5 +1,6 @@ import { createClient } from '@supabase/supabase-js'; +// Server-only Supabase client (only use in API routes and server components) if (!process.env.SUPABASE_URL) { throw new Error('Missing env.SUPABASE_URL'); } @@ -7,7 +8,6 @@ if (!process.env.SUPABASE_SERVICE_ROLE_KEY) { throw new Error('Missing env.SUPABASE_SERVICE_ROLE_KEY'); } -// Initialize Supabase client with service role key export const supabaseAdmin = createClient( process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY, @@ -17,4 +17,6 @@ export const supabaseAdmin = createClient( persistSession: false, }, } -); \ No newline at end of file +); + +export const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; \ No newline at end of file diff --git a/app/pages/TransactionForm.tsx b/app/pages/TransactionForm.tsx index 6e12efd7..2b6ab854 100644 --- a/app/pages/TransactionForm.tsx +++ b/app/pages/TransactionForm.tsx @@ -15,6 +15,7 @@ import { KycModal, FundWalletForm, AnimatedModal, + TransactionLimitModal, } from "../components"; import { BalanceSkeleton } from "../components/BalanceSkeleton"; import type { TransactionFormProps, Token } from "../types"; @@ -38,6 +39,7 @@ import { useNetwork, useTokens, } from "../context"; +import { useKYCStatus } from "../hooks/useKYCStatus"; /** * TransactionForm component renders a form for submitting a transaction. @@ -67,6 +69,7 @@ export const TransactionForm = ({ const { smartWalletBalance, injectedWalletBalance, isLoading } = useBalance(); const { isInjectedWallet, injectedAddress } = useInjectedWallet(); const { allTokens } = useTokens(); + const { canTransact, tier, isPhoneVerified, refreshStatus } = useKYCStatus(); const embeddedWalletAddress = wallets.find( (wallet) => wallet.walletClientType === "privy", @@ -79,6 +82,8 @@ export const TransactionForm = ({ const [formattedReceivedAmount, setFormattedReceivedAmount] = useState(""); const isFirstRender = useRef(true); const [rateError, setRateError] = useState(null); + const [isLimitModalOpen, setIsLimitModalOpen] = useState(false); + const [blockedTransactionAmount, setBlockedTransactionAmount] = useState(0); const currencies = useMemo( () => @@ -99,6 +104,7 @@ export const TransactionForm = ({ handleSubmit, watch, setValue, + getValues, formState: { errors, isValid, isDirty }, } = formMethods; const { amountSent, amountReceived, token, currency } = watch(); @@ -274,9 +280,10 @@ export const TransactionForm = ({ try { const response = await fetchKYCStatus(walletAddressToCheck); if (response.data.status === "pending") { - setIsKycModalOpen(true); + setIsLimitModalOpen(true); } else if (response.data.status === "success") { setIsUserVerified(true); + await refreshStatus(); // Refresh KYC tier status } } catch (error) { if ( @@ -452,6 +459,21 @@ export const TransactionForm = ({ const handleSwap = () => { setOrderId(""); + + // Calculate the USD amount for transaction limit checking + const formData = getValues(); + const usdAmount = formData.amountReceived || 0; + + // Check transaction limits based on KYC tier + const limitCheck = canTransact(usdAmount); + + if (!limitCheck.allowed) { + setBlockedTransactionAmount(usdAmount); + setIsLimitModalOpen(true); + return; + } + + // If limits are okay, proceed with transaction handleSubmit(onSubmit)(); }; @@ -828,6 +850,12 @@ export const TransactionForm = ({ )} + setIsLimitModalOpen(false)} + transactionAmount={blockedTransactionAmount} + /> + {/* Loading and Submit buttons */} {!ready && (
    - {/* @ts-ignore */} + {/* @ts-expect-error - SmileID web component, types handled by global declaration */} { - console.log("๐Ÿ”— Ref callback called with:", el); - setCameraElement(el); - }} + setCameraElement(el); + }} theme-color="#8B85F4" capture-id />
    {isCapturing && ( -
    - Processing your verification... -
    - )} +
    + Processing your verification... +
    + )} + +
    + ); + + const renderIdInfo = () => ( + +
    + +
    +

    + Select your ID document +

    +

    + Choose your country and the type of ID you'll use for verification. +

    +
    +
    + +
    + {/* Country Selection */} +
    + + ({ name: c.code, label: c.name }))} + selectedItem={selectedCountry?.code} + onSelect={(code) => { + const country = countries.find((c) => c.code === code); + setSelectedCountry(country || null); + setSelectedIdType(""); + setIdNumber(""); + }} + mobileTitle="Select Country" + dropdownWidth={350} + > + {({ selectedItem, isOpen, toggleDropdown }) => ( + + )} + +
    + + {/* ID Type Selection */} + {selectedCountry && ( +
    + + ({ + name: t.type, + label: t.type.replace(/_/g, " "), + }))} + selectedItem={selectedIdType} + onSelect={(type) => { + setSelectedIdType(type); + setIdNumber(""); + }} + mobileTitle="Select ID Type" + dropdownWidth={350} + > + {({ selectedItem, isOpen, toggleDropdown }) => ( + + )} + +
    + )} + + {/* ID Number Input - only for biometric_kyc verification (BVN, NIN, etc.) */} + {selectedIdType && !needsDocCapture && ( +
    + + setIdNumber(e.target.value)} + placeholder={`Enter your ${selectedIdType.replace(/_/g, " ")} number`} + className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 dark:border-white/10 dark:bg-white/5 dark:text-white focus:outline-none focus:ring-2 focus:ring-lavender-500 placeholder:text-gray-400" + /> +

    + Your ID will be verified against the government database +

    +
    + )} +
    + +
    + +
    @@ -312,28 +409,36 @@ export const KycModal = ({

    - Capture your documents + {needsDocCapture ? "Capture your documents" : "Take a selfie"}

    - Please take a selfie and capture your ID document for verification. + {needsDocCapture + ? "Please take a selfie and capture your ID document for verification." + : "Please take a selfie to verify your identity against your ID."}

    - {/* @ts-expect-error - SmileID web component, types handled by global declaration */} - { - setCameraElement(el); - }} - theme-color="#8B85F4" - capture-id - /> + {needsDocCapture ? ( + /* @ts-expect-error - SmileID web component */ + setCameraElement(el)} + theme-color="#8B85F4" + capture-id + /> + ) : ( + /* @ts-expect-error - SmileID web component */ + setCameraElement(el)} + theme-color="#8B85F4" + /> + )}
    + )} + + )} + {/* Current Tier Status */} - {tier >= 1 && tier !== undefined && ( + {tier >= 1 && tier !== undefined && phoneNumber && (
    {/* Current Tier Badge */} @@ -333,7 +384,7 @@ export default function ProfileDrawer({ isOpen, onClose }: ProfileDrawerProps) {
    {/* Upgrade Button */} - {tier < 2 && ( + {tier <= 2 && isFullyVerified && ( ); diff --git a/app/components/ProfileDrawer.tsx b/app/components/ProfileDrawer.tsx index 4c64f9e8..876f9b88 100644 --- a/app/components/ProfileDrawer.tsx +++ b/app/components/ProfileDrawer.tsx @@ -29,7 +29,6 @@ export default function ProfileDrawer({ isOpen, onClose }: ProfileDrawerProps) { const { user } = usePrivy(); const { tier, - phoneNumber, isFullyVerified, transactionSummary, getCurrentLimits, @@ -253,158 +252,109 @@ export default function ProfileDrawer({ isOpen, onClose }: ProfileDrawerProps) {
    {isLoading ? renderSkeletonLoader() : ( - <> - {/* Account info card */} -
    -

    Account

    - - {/* Email Connection */} -
    - -
    - {user?.email ? ( -
    -

    - {user.email.address} -

    -
    - ) : ( - - )} - - {/* Wallet Address */} -
    -

    - {shortenAddress(walletAddress ?? "", 10)} -

    - )} - + + {/* Wallet Address */} +
    +

    + {shortenAddress(walletAddress ?? "", 10)} +

    + +
    +
    -
    -
    - {tier >= 1 && tier !== undefined && !phoneNumber && ( -
    - {/* Current Tier Badge */} -
    - - - Current: {KYC_TIERS[tier]?.name || 'Tier 0'} - -
    + {/* Current Tier Status */} + {tier >= 1 && tier !== undefined && ( +
    + {/* Current Tier Badge */} - {/* Monthly Limit Progress */} -
    -
    - - Monthly limit - - -
    - -
    - ${formatNumberWithCommas(transactionSummary.monthlySpent)} / ${formatNumberWithCommas(currentLimits.monthly)} -
    - - {/* Progress Bar */} -
    -
    -
    -
    - - {/* Upgrade Button */} - {tier <= 2 && ( - - )} -
    - )} - - - {/* Current Tier Status */} - {tier >= 1 && tier !== undefined && phoneNumber && ( -
    - {/* Current Tier Badge */} - -
    - - - Current: {KYC_TIERS[tier]?.name || 'Tier 0'} - -
    +
    + + + Current: {KYC_TIERS[tier]?.name || 'Tier 0'} + +
    - {/* Monthly Limit Progress */} -
    -
    - - Monthly limit - - -
    + {/* Monthly Limit Progress */} +
    +
    + + Monthly limit + + +
    + +
    + ${formatNumberWithCommas(transactionSummary.monthlySpent)} / ${formatNumberWithCommas(currentLimits.monthly)} +
    + + {/* Progress Bar */} +
    +
    +
    +
    -
    - ${formatNumberWithCommas(transactionSummary.monthlySpent)} / ${formatNumberWithCommas(currentLimits.monthly)} -
    + {/* Upgrade Button */} + {tier < 2 && !isFullyVerified && ( + + )} +
    + )} - {/* Progress Bar */} -
    -
    -
    -
    + {/* Tier Information */} - {/* Upgrade Button */} - {tier < 2 && !isFullyVerified && ( - - )} -
    - )} - - {/* Tier Information */} - - {Object.values(KYC_TIERS) - .filter(tierData => tierData.level > tier) // Only show tiers above current - .map(tierData =>
    {renderTierSection(tierData.level)}
    )} - + {Object.values(KYC_TIERS) + .filter(tierData => tierData.level > tier) // Only show tiers above current + .map(tierData =>
    {renderTierSection(tierData.level)}
    )} + )}
    diff --git a/app/components/TransactionLimitModal.tsx b/app/components/TransactionLimitModal.tsx index 40fd19ae..26a21f49 100644 --- a/app/components/TransactionLimitModal.tsx +++ b/app/components/TransactionLimitModal.tsx @@ -48,7 +48,7 @@ export default function TransactionLimitModal({ // Auto-open phone verification modal if tier is less than 1 (unverified) useEffect(() => { - if (isOpen && !isLoading && (tier < 1 || tier === undefined || !phoneNumber)) { + if (isOpen && !isLoading && ((tier < 1 && !phoneNumber) || tier === undefined)) { setIsPhoneModalOpen(true); } }, [isOpen, isLoading, tier]); @@ -136,7 +136,7 @@ export default function TransactionLimitModal({ {tier < 1 ? ( <>Tier 0 gives you ${formatNumberWithCommas(currentLimits.monthly)}/month. Verify your ID to unlock ${nextTier ? formatNumberWithCommas(nextTier.limits.monthly) : '1,000'}/month and beyond. Learn more. ) : ( - <>You're currently at {currentTier?.name} with ${formatNumberWithCommas(currentLimits.monthly)}/month. {nextTier ? `Upgrade to ${nextTier.name} for ${formatNumberWithCommas(nextTier.limits.monthly)}/month` : 'You have the highest tier available'}. Learn more. + <>You're currently at {currentTier?.name} with ${formatNumberWithCommas(currentLimits.monthly)}/month. {nextTier ? `Upgrade to ${nextTier.name} for ${formatNumberWithCommas(nextTier.limits.monthly)}/month` : 'You have the highest tier available'}. Learn more. )}

    diff --git a/app/hooks/useKYCStatus.ts b/app/hooks/useKYCStatus.ts index f917d2af..ae504efe 100644 --- a/app/hooks/useKYCStatus.ts +++ b/app/hooks/useKYCStatus.ts @@ -105,7 +105,7 @@ export function useKYCStatus(): KYCStatus { } return { allowed: true }; - }, [getCurrentLimits, getRemainingLimits, tier]); + }, [getRemainingLimits]); const fetchTransactionSummary = useCallback(async () => { @@ -129,7 +129,7 @@ export function useKYCStatus(): KYCStatus { } finally { fetchGuards[`${guardKey}_tx`] = 'done'; } - }, [walletAddress]); + }, [walletAddress, fetchGuards, guardKey]); const fetchKYCStatus = useCallback(async () => { @@ -163,7 +163,7 @@ export function useKYCStatus(): KYCStatus { } finally { fetchGuards[`${guardKey}_kyc`] = 'done'; } - }, [walletAddress, getAccessToken]); + }, [walletAddress, getAccessToken, fetchGuards, guardKey]); const refreshStatus = useCallback(async () => { await Promise.all([ diff --git a/app/hooks/useSwapButton.ts b/app/hooks/useSwapButton.ts index 5f77223a..1383f63a 100644 --- a/app/hooks/useSwapButton.ts +++ b/app/hooks/useSwapButton.ts @@ -108,10 +108,10 @@ export function useSwapButton({ if (!authenticated && !isInjectedWallet) { return login; } - if (!hasInsufficientBalance && !isInjectedWallet && authenticated) { + if (hasInsufficientBalance && !isInjectedWallet && authenticated) { return handleFundWallet; } - if (hasInsufficientBalance && !isUserVerified && (authenticated || isInjectedWallet)) { + if (!hasInsufficientBalance && !isUserVerified && (authenticated || isInjectedWallet)) { return setIsLimitModalOpen; } return handleSwap; diff --git a/app/lib/smileID.ts b/app/lib/smileID.ts index 15f1ab3e..82078671 100644 --- a/app/lib/smileID.ts +++ b/app/lib/smileID.ts @@ -1,6 +1,13 @@ -import SIDCore from "smile-identity-core"; +let SIDWebAPI: any = null; -const SIDWebAPI = SIDCore.WebApi; +// Dynamically import SmileID only when needed to avoid build-time issues +async function getSIDWebAPI() { + if (!SIDWebAPI) { + const SIDCore = await import("smile-identity-core"); + SIDWebAPI = SIDCore.default.WebApi; + } + return SIDWebAPI; +} export type SmileIDIdInfo = { country: string; @@ -43,7 +50,8 @@ export async function submitSmileIDJob({ images, partner_params, walletAddress, const jobType = getJobTypeForIdType(id_info.id_type); // Initialize SmileID Web API - const connection = new SIDWebAPI( + const WebApiClass = await getSIDWebAPI(); + const connection = new WebApiClass( partnerId, callbackUrl, apiKey, diff --git a/app/pages/TransactionForm.tsx b/app/pages/TransactionForm.tsx index 3d452dac..bc192dda 100644 --- a/app/pages/TransactionForm.tsx +++ b/app/pages/TransactionForm.tsx @@ -30,7 +30,6 @@ import { } from "../utils"; import { ArrowDown02Icon, NoteEditIcon, Wallet01Icon } from "hugeicons-react"; import { useSwapButton } from "../hooks/useSwapButton"; -import { fetchKYCStatus } from "../api/aggregator"; import { useCNGNRate } from "../hooks/useCNGNRate"; import { useFundWalletHandler } from "../hooks/useFundWalletHandler"; import { @@ -69,7 +68,7 @@ export const TransactionForm = ({ const { smartWalletBalance, injectedWalletBalance, isLoading } = useBalance(); const { isInjectedWallet, injectedAddress } = useInjectedWallet(); const { allTokens } = useTokens(); - const { canTransact, tier, isPhoneVerified, refreshStatus } = useKYCStatus(); + const { canTransact, refreshStatus, isFullyVerified } = useKYCStatus(); const embeddedWalletAddress = wallets.find( (wallet) => wallet.walletClientType === "privy", @@ -270,34 +269,13 @@ export const TransactionForm = ({ ); useEffect( - function checkKycStatus() { + function refreshKycStatus() { const walletAddressToCheck = isInjectedWallet ? injectedAddress : embeddedWalletAddress; if (!walletAddressToCheck) return; - const fetchStatus = async () => { - try { - const response = await fetchKYCStatus(walletAddressToCheck); - if (response.data.status === "pending") { - setIsLimitModalOpen(true); - } else if (response.data.status === "success") { - setIsUserVerified(true); - await refreshStatus(); // Refresh KYC tier status - } - } catch (error) { - if ( - error instanceof Error && - (error as any).response?.status === 404 - ) { - // silently fail if user is not found/verified - } else { - console.log("error", error); - } - } - }; - - fetchStatus(); + refreshStatus(); }, // eslint-disable-next-line react-hooks/exhaustive-deps [embeddedWalletAddress, injectedAddress, isInjectedWallet], @@ -452,7 +430,7 @@ export const TransactionForm = ({ balance, isDirty, isValid, - isUserVerified, + isUserVerified: isFullyVerified, rate, tokenDecimals, }); @@ -810,7 +788,7 @@ export const TransactionForm = ({ {currency && (authenticated || isInjectedWallet) && - isUserVerified && ( + isFullyVerified && ( setIsLimitModalOpen(true), - isUserVerified, + isFullyVerified, )} > {buttonText} From d0087a248949d14ac226970aa53c2ad6da6e1181 Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Mon, 8 Dec 2025 20:24:58 +0100 Subject: [PATCH 14/22] feat: Update phone verification modal text to include consent for transactional messages and link to privacy policy --- app/components/PhoneVerificationModal.tsx | 2 +- pnpm-lock.yaml | 169 +++++++++++++++++++++- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/app/components/PhoneVerificationModal.tsx b/app/components/PhoneVerificationModal.tsx index 8f182752..2e8c1702 100644 --- a/app/components/PhoneVerificationModal.tsx +++ b/app/components/PhoneVerificationModal.tsx @@ -374,7 +374,7 @@ export default function PhoneVerificationModal({
    diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33ec47a4..34ac3c2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@sanity/image-url': specifier: ^1.1.0 version: 1.1.0 + '@smileid/web-components': + specifier: ^11.0.2 + version: 11.0.3 '@supabase/supabase-js': specifier: ^2.50.0 version: 2.50.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -84,6 +87,9 @@ importers: jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 + libphonenumber-js: + specifier: ^1.12.27 + version: 1.12.31 lru-cache: specifier: ^11.1.0 version: 11.1.0 @@ -129,6 +135,9 @@ importers: sanity: specifier: ^4.4.1 version: 4.4.1(@emotion/is-prop-valid@1.3.1)(@portabletext/sanity-bridge@1.1.2(@sanity/schema@4.4.1(@types/react@19.0.8)(debug@4.4.1))(@sanity/types@4.4.1(@types/react@19.0.8)(debug@4.4.1)))(@types/node@22.15.31)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(bufferutil@4.0.9)(immer@10.1.1)(jiti@2.5.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(styled-components@6.1.19(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.1) + smile-identity-core: + specifier: ^3.1.0 + version: 3.1.0 sonner: specifier: ^1.7.4 version: 1.7.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -141,6 +150,9 @@ importers: tls: specifier: ^0.0.1 version: 0.0.1 + twilio: + specifier: ^5.10.5 + version: 5.10.7 viem: specifier: ^2.31.0 version: 2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.24) @@ -2226,6 +2238,9 @@ packages: '@maverick-js/signals@5.11.5': resolution: {integrity: sha512-/GO94awrwN9ROYZDMTeByordjvbhcm3CMvB/2aL/sEUy9Va8nM/2GmNgOOe+rrooTGnz8/DzO73xomuBRrnYWw==} + '@mediapipe/tasks-vision@0.10.22-rc.20250304': + resolution: {integrity: sha512-dElxVXMFGthshfIj+qAVm8KE2jmNo2p8oXFib8WzEjb7GNaX/ClWBc8UJfoSZwjEMVrdHJ4YUfa7P3ifl6MIWw==} + '@metamask/eth-json-rpc-provider@1.0.1': resolution: {integrity: sha512-whiUMPlAOrVGmX8aKYVPvlKyG4CpQXiNNyt74vE1xb5sPvmx5oA7B/kOi/JdBvhGQq97U1/AVdXEdk2zkP8qyA==} engines: {node: '>=14.0.0'} @@ -2587,6 +2602,14 @@ packages: resolution: {integrity: sha512-5xk5MSyQU9CrDho3Rsguj38jhijhD36Mk8S6mZo3huv6PM+t4M/5kJN2KFIxgvt4ONpvOEs1pVIZAV0cL0Vi+Q==} engines: {node: ^14.13.1 || >=16.0.0 || >=18.0.0} + '@preact/signals-core@1.12.1': + resolution: {integrity: sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==} + + '@preact/signals@2.5.1': + resolution: {integrity: sha512-VPjk5YFt7i11Fi4UK0tzaEe5xLwfhUxXL3l89ocxQ5aPz7bRo8M5+N73LjBMPklyXKYKz6YsNo4Smp8n6nplng==} + peerDependencies: + preact: '>= 10.25.0 || >=11.0.0-0' + '@privy-io/api-base@1.5.1': resolution: {integrity: sha512-UokueOxl2hoW+kfFTzwV8uqwCNajSaJJEGSWHpsuKvdDQ8ePwXe53Gr5ptnKznaZlMLivc25mrv92bVEJbclfQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3807,6 +3830,9 @@ packages: resolution: {integrity: sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + '@smileid/web-components@11.0.3': + resolution: {integrity: sha512-bKL01ahiAa9pmD/Bp4y08qY2qt37ItNbZDG+FjJHgnPNJIdOJWO7o6w/uZwAB5Qeyw4Q7yoczwzV+v0rk+zA2Q==} + '@solana/buffer-layout@4.0.1': resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} engines: {node: '>=5.10'} @@ -3921,6 +3947,14 @@ packages: '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@tabler/icons-preact@3.35.0': + resolution: {integrity: sha512-SGM2Z29xhED6GN4OicNZ6QffvaM1Iz/jNO7jXiVVxeRpq0/c1h+ydKkH1g8bOoz9e1ZQdydCUiM1OlHJ5bjotA==} + peerDependencies: + preact: ^10.5.13 + + '@tabler/icons@3.35.0': + resolution: {integrity: sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ==} + '@tanstack/query-core@5.29.0': resolution: {integrity: sha512-WgPTRs58hm9CMzEr5jpISe8HXa3qKQ8CxewdYZeVnA54JrPY9B1CZiwsCoLpLkf0dGRZq+LcX5OiJb0bEsOFww==} @@ -4926,6 +4960,9 @@ packages: axios@0.27.2: resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + axios@1.9.0: resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==} @@ -7350,6 +7387,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@1.4.2: resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} @@ -7406,12 +7446,15 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libphonenumber-js@1.12.9: - resolution: {integrity: sha512-VWwAdNeJgN7jFOD+wN4qx83DTPMVPPAUyx9/TUkBXKLiNkuWWk6anV0439tgdtwaJDrEdqkvdN22iA6J4bUCZg==} + libphonenumber-js@1.12.31: + resolution: {integrity: sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ==} lie@3.1.1: resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + light-my-request@5.14.0: resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==} @@ -8419,6 +8462,16 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + preact-custom-element@4.6.0: + resolution: {integrity: sha512-RhPMuFpEIsbpEwnF+MoRbddzX0y8AdLn1dSpJSbpc6Nx3aSAPXSW9t2xAqQyGfEH1sl/bcsi8Vf0XbvStUy/Ug==} + peerDependencies: + preact: '>= 10.25.0 || >=11.0.0-0' + + preact-router@4.1.2: + resolution: {integrity: sha512-uICUaUFYh+XQ+6vZtQn1q+X6rSqwq+zorWOCLWPF5FAsQh3EJ+RsDQ9Ee+fjk545YWQHfUxhrBAaemfxEnMOUg==} + peerDependencies: + preact: '>=10' + preact@10.24.2: resolution: {integrity: sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==} @@ -9039,6 +9092,9 @@ packages: scheduler@0.25.0-rc-603e6108-20241029: resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==} + scmp@2.1.0: + resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} + scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} @@ -9151,6 +9207,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + signature_pad@5.1.3: + resolution: {integrity: sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -9186,6 +9245,10 @@ packages: slate@0.118.0: resolution: {integrity: sha512-XAHgaoN3IikTz83DlJWZWR7e4SjuRn1Ps6I717fL7yaITF7zhZm5z8zbU+TaPlHu4APCV6TCMIF33EZdW3GqfQ==} + smile-identity-core@3.1.0: + resolution: {integrity: sha512-ZTIhHEJQS40ubqB3gbSLS098TuH2loXXiLyDJLM5DQNtMJb8E6K+HEGn2ZRn5GKm0CKIZK35cArNTfiM1BACOQ==} + engines: {node: '>=v12.0.0'} + solady@0.0.180: resolution: {integrity: sha512-9QVCyMph+wk78Aq/GxtDAQg7dvNoVWx2dS2Zwf11XlwFKDZ+YJG2lrQsK9NEIth9NOebwjBXAYk4itdwOOE4aw==} @@ -9764,6 +9827,10 @@ packages: tweetnacl@1.0.3: resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + twilio@5.10.7: + resolution: {integrity: sha512-pELNeyQqkJMW/UkbcdNGDfOjMyt1FXoYOKXBDqOViHmcdJ04cq8Ty3VsrEAabi97YesK3T2v5KP5XKPY8uUx9w==} + engines: {node: '>=14.0'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -10106,6 +10173,9 @@ packages: validate-npm-package-name@3.0.0: resolution: {integrity: sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==} + validate.js@0.13.1: + resolution: {integrity: sha512-PnFM3xiZ+kYmLyTiMgTYmU7ZHkjBZz2/+F0DaALc/uUtVzdCt1wAosvYJ5hFQi/hz8O4zb52FQhHZRC+uVkJ+g==} + valtio@1.11.2: resolution: {integrity: sha512-1XfIxnUXzyswPAPXo1P3Pdx2mq/pIqZICkWN60Hby0d9Iqb+MEIpqgYVlbflvHdrp2YR/q3jyKWRPJJ100yxaw==} engines: {node: '>=12.20.0'} @@ -10596,6 +10666,10 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xmlbuilder@13.0.2: + resolution: {integrity: sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==} + engines: {node: '>=6.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -13962,6 +14036,8 @@ snapshots: '@maverick-js/signals@5.11.5': {} + '@mediapipe/tasks-vision@0.10.22-rc.20250304': {} + '@metamask/eth-json-rpc-provider@1.0.1': dependencies: '@metamask/json-rpc-engine': 7.3.3 @@ -14404,6 +14480,13 @@ snapshots: '@portabletext/types@2.0.13': {} + '@preact/signals-core@1.12.1': {} + + '@preact/signals@2.5.1(preact@10.26.9)': + dependencies: + '@preact/signals-core': 1.12.1 + preact: 10.26.9 + '@privy-io/api-base@1.5.1': dependencies: zod: 3.25.62 @@ -14429,7 +14512,7 @@ snapshots: fetch-retry: 6.0.0 jose: 4.15.9 js-cookie: 3.0.5 - libphonenumber-js: 1.12.9 + libphonenumber-js: 1.12.31 set-cookie-parser: 2.7.1 uuid: 9.0.1 optionalDependencies: @@ -14444,7 +14527,7 @@ snapshots: dependencies: '@privy-io/api-base': 1.5.1 bs58: 5.0.0 - libphonenumber-js: 1.12.9 + libphonenumber-js: 1.12.31 viem: 2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.62) zod: 3.25.62 transitivePeerDependencies: @@ -14456,7 +14539,7 @@ snapshots: dependencies: '@privy-io/api-base': 1.7.1 bs58: 5.0.0 - libphonenumber-js: 1.12.9 + libphonenumber-js: 1.12.31 viem: 2.38.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) zod: 3.25.76 transitivePeerDependencies: @@ -16537,6 +16620,18 @@ snapshots: '@simplewebauthn/types@9.0.1': {} + '@smileid/web-components@11.0.3': + dependencies: + '@mediapipe/tasks-vision': 0.10.22-rc.20250304 + '@preact/signals': 2.5.1(preact@10.26.9) + '@tabler/icons-preact': 3.35.0(preact@10.26.9) + lodash: 4.17.21 + preact: 10.26.9 + preact-custom-element: 4.6.0(preact@10.26.9) + preact-router: 4.1.2(preact@10.26.9) + signature_pad: 5.1.3 + validate.js: 0.13.1 + '@solana/buffer-layout@4.0.1': dependencies: buffer: 6.0.3 @@ -16715,6 +16810,13 @@ snapshots: dependencies: tslib: 2.8.1 + '@tabler/icons-preact@3.35.0(preact@10.26.9)': + dependencies: + '@tabler/icons': 3.35.0 + preact: 10.26.9 + + '@tabler/icons@3.35.0': {} + '@tanstack/query-core@5.29.0': {} '@tanstack/query-core@5.74.4': {} @@ -19522,6 +19624,14 @@ snapshots: transitivePeerDependencies: - debug + axios@1.13.2: + dependencies: + follow-redirects: 1.15.9(debug@4.4.1) + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axios@1.9.0: dependencies: follow-redirects: 1.15.9(debug@4.4.1) @@ -22511,6 +22621,13 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + jwa@1.4.2: dependencies: buffer-equal-constant-time: 1.0.1 @@ -22575,12 +22692,16 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - libphonenumber-js@1.12.9: {} + libphonenumber-js@1.12.31: {} lie@3.1.1: dependencies: immediate: 3.0.6 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + light-my-request@5.14.0: dependencies: cookie: 0.7.2 @@ -23756,6 +23877,14 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact-custom-element@4.6.0(preact@10.26.9): + dependencies: + preact: 10.26.9 + + preact-router@4.1.2(preact@10.26.9): + dependencies: + preact: 10.26.9 + preact@10.24.2: {} preact@10.26.9: {} @@ -24548,6 +24677,8 @@ snapshots: scheduler@0.25.0-rc-603e6108-20241029: {} + scmp@2.1.0: {} + scroll-into-view-if-needed@3.1.0: dependencies: compute-scroll-into-view: 3.1.1 @@ -24690,6 +24821,8 @@ snapshots: signal-exit@4.1.0: {} + signature_pad@5.1.3: {} + simple-concat@1.0.1: {} simple-get@2.8.2: @@ -24737,6 +24870,13 @@ snapshots: immer: 10.1.1 tiny-warning: 1.0.3 + smile-identity-core@3.1.0: + dependencies: + axios: 1.9.0 + jszip: 3.10.1 + transitivePeerDependencies: + - debug + solady@0.0.180: {} sonic-boom@2.8.0: @@ -25394,6 +25534,19 @@ snapshots: tweetnacl@1.0.3: {} + twilio@5.10.7: + dependencies: + axios: 1.13.2 + dayjs: 1.11.13 + https-proxy-agent: 5.0.1 + jsonwebtoken: 9.0.2 + qs: 6.14.0 + scmp: 2.1.0 + xmlbuilder: 13.0.2 + transitivePeerDependencies: + - debug + - supports-color + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -25701,6 +25854,8 @@ snapshots: dependencies: builtins: 1.0.3 + validate.js@0.13.1: {} + valtio@1.11.2(@types/react@19.0.8)(react@19.0.0): dependencies: proxy-compare: 2.5.1 @@ -26341,6 +26496,8 @@ snapshots: xml-name-validator@5.0.0: {} + xmlbuilder@13.0.2: {} + xmlchars@2.2.0: {} xregexp@2.0.0: {} From f2368d7cdb1f404b97f7ba7dc9de7637ed3bd5c9 Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Mon, 26 Jan 2026 15:27:15 +0100 Subject: [PATCH 15/22] feat: replace termii with kudi sms to handle nigerian sms otp --- .env.example | 10 +- app/api/phone/send-otp/route.ts | 145 ++-- app/components/PhoneVerificationModal.tsx | 517 ++++++------ app/components/ProfileDrawer.tsx | 757 ++++++++++-------- app/components/TransactionLimitModal.tsx | 440 +++++----- app/lib/phone-verification.ts | 95 +-- app/pages/TransactionForm.tsx | 12 +- .../migrations/create_user_kyc_profiles.sql | 77 +- 8 files changed, 1119 insertions(+), 934 deletions(-) diff --git a/.env.example b/.env.example index 14804377..57cdf256 100644 --- a/.env.example +++ b/.env.example @@ -99,10 +99,12 @@ NEXT_PUBLIC_BREVO_CONVERSATIONS_GROUP_ID= # Phone Verification Services # ============================================================================= -# Termii (for African phone numbers) -# Get from: Termii Dashboard โ†’ Settings โ†’ API Keys -TERMII_API_KEY=your_termii_api_key -TERMII_SENDER_ID=Noblocks +# KudiSMS (for African phone numbers) +# Get from: KudiSMS Dashboard โ†’ Settings โ†’ API Keys +KUDISMS_API_KEY=your_kudisms_api_key +KUDISMS_APP_NAME_CODE=your_app_name_code +KUDISMS_TEMPLATE_CODE=your_template_code +KUDISMS_SENDER_ID=Noblocks # Twilio (for international phone numbers) # Get from: Twilio Console โ†’ Account Dashboard diff --git a/app/api/phone/send-otp/route.ts b/app/api/phone/send-otp/route.ts index 34c4b9b7..7b45a222 100644 --- a/app/api/phone/send-otp/route.ts +++ b/app/api/phone/send-otp/route.ts @@ -1,13 +1,17 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { supabaseAdmin } from '@/app/lib/supabase'; +import { NextRequest, NextResponse } from "next/server"; +import { supabaseAdmin } from "@/app/lib/supabase"; import { validatePhoneNumber, - sendTermiiOTP, + sendKudiSMSOTP, sendTwilioOTP, - generateOTP -} from '../../../lib/phone-verification'; -import { trackApiRequest, trackApiResponse, trackApiError } from '../../../lib/server-analytics'; -import { rateLimit } from '@/app/lib/rate-limit'; + generateOTP, +} from "../../../lib/phone-verification"; +import { + trackApiRequest, + trackApiResponse, + trackApiError, +} from "../../../lib/server-analytics"; +import { rateLimit } from "@/app/lib/rate-limit"; export async function POST(request: NextRequest) { const startTime = Date.now(); @@ -17,31 +21,46 @@ export async function POST(request: NextRequest) { const rateLimitResult = await rateLimit(request); if (!rateLimitResult.success) { return NextResponse.json( - { success: false, error: 'Too many requests. Please try again later.' }, - { status: 429 } + { success: false, error: "Too many requests. Please try again later." }, + { status: 429 }, ); } - trackApiRequest(request, '/api/phone/send-otp', 'POST'); + trackApiRequest(request, "/api/phone/send-otp", "POST"); const body = await request.json(); const { phoneNumber, walletAddress, name } = body; if (!phoneNumber || !walletAddress) { - trackApiError(request, '/api/phone/send-otp', 'POST', new Error('Missing required fields'), 400); + trackApiError( + request, + "/api/phone/send-otp", + "POST", + new Error("Missing required fields"), + 400, + ); return NextResponse.json( - { success: false, error: 'Phone number and wallet address are required' }, - { status: 400 } + { + success: false, + error: "Phone number and wallet address are required", + }, + { status: 400 }, ); } // Validate phone number const validation = validatePhoneNumber(phoneNumber); if (!validation.isValid) { - trackApiError(request, '/api/phone/send-otp', 'POST', new Error('Invalid phone number format'), 400); + trackApiError( + request, + "/api/phone/send-otp", + "POST", + new Error("Invalid phone number format"), + 400, + ); return NextResponse.json( - { success: false, error: 'Invalid phone number format' }, - { status: 400 } + { success: false, error: "Invalid phone number format" }, + { status: 400 }, ); } @@ -51,58 +70,71 @@ export async function POST(request: NextRequest) { // Get existing profile to preserve important fields const { data: existingProfile } = await supabaseAdmin - .from('user_kyc_profiles') - .select('tier, verified, verified_at, id_country, id_type, platform, full_name') - .eq('wallet_address', walletAddress.toLowerCase()) + .from("user_kyc_profiles") + .select( + "tier, verified, verified_at, id_country, id_type, platform, full_name", + ) + .eq("wallet_address", walletAddress.toLowerCase()) .single(); // Store OTP in database const { error: dbError } = await supabaseAdmin - .from('user_kyc_profiles') - .upsert({ - wallet_address: walletAddress.toLowerCase(), - full_name: name || existingProfile?.full_name || null, - phone_number: validation.e164Format, // Store in E.164 format (no spaces) - otp_code: otp, - expires_at: expiresAt.toISOString(), - verified: existingProfile?.verified || false, - verified_at: existingProfile?.verified_at || null, - tier: existingProfile?.tier || 0, - // Preserve existing ID verification data - id_country: existingProfile?.id_country || null, - id_type: existingProfile?.id_type || null, - platform: existingProfile?.platform || null, - attempts: 0, - provider: validation.provider, - }, { - onConflict: 'wallet_address' - }); + .from("user_kyc_profiles") + .upsert( + { + wallet_address: walletAddress.toLowerCase(), + full_name: name || existingProfile?.full_name || null, + phone_number: validation.e164Format, // Store in E.164 format (no spaces) + otp_code: otp, + expires_at: expiresAt.toISOString(), + verified: existingProfile?.verified || false, + verified_at: existingProfile?.verified_at || null, + tier: existingProfile?.tier || 0, + // Preserve existing ID verification data + id_country: existingProfile?.id_country || null, + id_type: existingProfile?.id_type || null, + platform: existingProfile?.platform || null, + attempts: 0, + provider: validation.provider, + }, + { + onConflict: "wallet_address", + }, + ); if (dbError) { - console.error('Database error:', dbError); - trackApiError(request, '/api/phone/send-otp', 'POST', dbError, 500); + console.error("Database error:", dbError); + trackApiError(request, "/api/phone/send-otp", "POST", dbError, 500); return NextResponse.json( - { success: false, error: 'Failed to store verification data' }, - { status: 500 } + { success: false, error: "Failed to store verification data" }, + { status: 500 }, ); } - // Use digitsOnly for Termii, e164Format for Twilio + // Use digitsOnly for KudiSMS, e164Format for Twilio let result; - if (validation.isAfrican) { - result = await sendTermiiOTP(validation.digitsOnly!, otp); + if (validation.isNigerian) { + result = await sendKudiSMSOTP(validation.digitsOnly!, otp); } else { result = await sendTwilioOTP(validation.e164Format!, otp); } const responseTime = Date.now() - startTime; - trackApiResponse('/api/phone/send-otp', 'POST', result.success ? 200 : 400, responseTime); + trackApiResponse( + "/api/phone/send-otp", + "POST", + result.success ? 200 : 400, + responseTime, + ); if (!result.success) { - return NextResponse.json({ - success: false, - error: result.error || result.message, - }, { status: 400 }); + return NextResponse.json( + { + success: false, + error: result.error || result.message, + }, + { status: 400 }, + ); } return NextResponse.json({ @@ -111,14 +143,13 @@ export async function POST(request: NextRequest) { provider: validation.provider, phoneNumber: validation.internationalFormat, }); - } catch (error) { - console.error('Send OTP error:', error); - trackApiError(request, '/api/phone/send-otp', 'POST', error as Error, 500); - + console.error("Send OTP error:", error); + trackApiError(request, "/api/phone/send-otp", "POST", error as Error, 500); + return NextResponse.json( - { success: false, error: 'Internal server error' }, - { status: 500 } + { success: false, error: "Internal server error" }, + { status: 500 }, ); } -} \ No newline at end of file +} diff --git a/app/components/PhoneVerificationModal.tsx b/app/components/PhoneVerificationModal.tsx index 2e8c1702..cf3a1f98 100644 --- a/app/components/PhoneVerificationModal.tsx +++ b/app/components/PhoneVerificationModal.tsx @@ -1,15 +1,29 @@ "use client"; -import { useState, useCallback, useEffect, useRef } from 'react'; -import { toast } from 'sonner'; -import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { useWallets } from '@privy-io/react-auth'; -import { Cancel01Icon, CheckmarkCircle01Icon, AiPhone01Icon, Message01Icon, ArrowDown01Icon, TelephoneIcon, InformationSquareIcon, ArrowLeft02Icon } from 'hugeicons-react'; -import { parsePhoneNumber } from 'libphonenumber-js'; -import { primaryBtnClasses, secondaryBtnClasses } from './Styles'; -import { fadeInOut, AnimatedComponent, slideInOut } from './AnimatedComponents'; -import { classNames } from '../utils'; -import { fetchCountries, getPopularCountries, searchCountries, type Country } from '../lib/countries'; +import { useState, useCallback, useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useWallets } from "@privy-io/react-auth"; +import { + Cancel01Icon, + CheckmarkCircle01Icon, + AiPhone01Icon, + Message01Icon, + ArrowDown01Icon, + TelephoneIcon, + InformationSquareIcon, + ArrowLeft02Icon, +} from "hugeicons-react"; +import { parsePhoneNumber } from "libphonenumber-js"; +import { primaryBtnClasses, secondaryBtnClasses } from "./Styles"; +import { fadeInOut, AnimatedComponent, slideInOut } from "./AnimatedComponents"; +import { classNames } from "../utils"; +import { + fetchCountries, + getPopularCountries, + searchCountries, + type Country, +} from "../lib/countries"; interface PhoneVerificationModalProps { isOpen: boolean; @@ -18,43 +32,43 @@ interface PhoneVerificationModalProps { } const STEPS = { - ENTER_PHONE: 'enter_phone', - ENTER_OTP: 'enter_otp', - VERIFIED: 'verified' + ENTER_PHONE: "enter_phone", + ENTER_OTP: "enter_otp", + VERIFIED: "verified", } as const; -type Step = typeof STEPS[keyof typeof STEPS]; +type Step = (typeof STEPS)[keyof typeof STEPS]; export default function PhoneVerificationModal({ isOpen, onClose, - onVerified + onVerified, }: PhoneVerificationModalProps) { const { wallets } = useWallets(); - + const embeddedWallet = wallets.find( (wallet) => wallet.walletClientType === "privy", ); const walletAddress = embeddedWallet?.address; const [step, setStep] = useState(STEPS.ENTER_PHONE); - const [name, setName] = useState(''); - const [phoneNumber, setPhoneNumber] = useState(''); - const [formattedPhone, setFormattedPhone] = useState(''); - const [otpCode, setOtpCode] = useState(''); + const [name, setName] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [formattedPhone, setFormattedPhone] = useState(""); + const [otpCode, setOtpCode] = useState(""); const [isLoading, setIsLoading] = useState(false); - const [provider, setProvider] = useState<'termii' | 'twilio'>('termii'); + const [provider, setProvider] = useState<"kudisms" | "twilio">("kudisms"); const [attemptsRemaining, setAttemptsRemaining] = useState(3); const [selectedCountry, setSelectedCountry] = useState({ - code: '+234', - flag: 'https://flagcdn.com/w40/ng.png', - name: 'Nigeria', - country: 'NG' + code: "+234", + flag: "https://flagcdn.com/w40/ng.png", + name: "Nigeria", + country: "NG", }); const [isCountryDropdownOpen, setIsCountryDropdownOpen] = useState(false); const [countries, setCountries] = useState([]); const [filteredCountries, setFilteredCountries] = useState([]); - const [countrySearch, setCountrySearch] = useState(''); + const [countrySearch, setCountrySearch] = useState(""); const [isLoadingCountries, setIsLoadingCountries] = useState(false); const dropdownRef = useRef(null); @@ -66,16 +80,16 @@ export default function PhoneVerificationModal({ .then((data) => { setCountries(data); setFilteredCountries(data); - + // Set Nigeria as default if available - const nigeria = data.find(c => c.country === 'NG'); + const nigeria = data.find((c) => c.country === "NG"); if (nigeria) { setSelectedCountry(nigeria); } }) .catch((error) => { - console.error('Failed to load countries:', error); - toast.error('Failed to load countries. Using defaults.'); + console.error("Failed to load countries:", error); + toast.error("Failed to load countries. Using defaults."); }) .finally(() => { setIsLoadingCountries(false); @@ -90,8 +104,12 @@ export default function PhoneVerificationModal({ } else { // Show popular countries first, then the rest const popularCountryCodes = getPopularCountries(); - const popular = countries.filter(c => popularCountryCodes.includes(c.country)); - const others = countries.filter(c => !popularCountryCodes.includes(c.country)); + const popular = countries.filter((c) => + popularCountryCodes.includes(c.country), + ); + const others = countries.filter( + (c) => !popularCountryCodes.includes(c.country), + ); setFilteredCountries([...popular, ...others]); } }, [countrySearch, countries]); @@ -99,50 +117,54 @@ export default function PhoneVerificationModal({ // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { setIsCountryDropdownOpen(false); - setCountrySearch(''); + setCountrySearch(""); } }; if (isCountryDropdownOpen) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); } }, [isCountryDropdownOpen]); const handlePhoneSubmit = useCallback(async () => { if (!phoneNumber.trim() || !walletAddress) { - toast.error('Please enter a valid phone number'); + toast.error("Please enter a valid phone number"); return; } try { // Combine selected country code with phone number let fullPhoneNumber = phoneNumber.trim(); - if (!fullPhoneNumber.startsWith('+')) { + if (!fullPhoneNumber.startsWith("+")) { // Remove any leading zeros and add selected country code - fullPhoneNumber = fullPhoneNumber.replace(/^0+/, ''); + fullPhoneNumber = fullPhoneNumber.replace(/^0+/, ""); fullPhoneNumber = selectedCountry.code + fullPhoneNumber; } // Validate phone number format const parsed = parsePhoneNumber(fullPhoneNumber); if (!parsed || !parsed.isValid()) { - toast.error('Please enter a valid phone number'); + toast.error("Please enter a valid phone number"); return; } setIsLoading(true); - const response = await fetch('/api/phone/send-otp', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const response = await fetch("/api/phone/send-otp", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phoneNumber: fullPhoneNumber, walletAddress: walletAddress, - name: name - }) + name: name, + }), }); const data = await response.json(); @@ -151,13 +173,19 @@ export default function PhoneVerificationModal({ setFormattedPhone(data.phoneNumber); setProvider(data.provider); setStep(STEPS.ENTER_OTP); - toast.success(`OTP sent via ${data.provider === 'termii' ? 'Termii' : 'Twilio'}`); + const providerName = + data.provider === "kudisms" + ? "KudiSMS" + : data.provider === "termii" + ? "Termii" + : "Twilio"; + toast.success(`OTP sent via ${providerName}`); } else { - toast.error(data.error || 'Failed to send OTP'); + toast.error(data.error || "Failed to send OTP"); } } catch (error) { - console.error('Phone submission error:', error); - toast.error('Failed to send OTP. Please try again.'); + console.error("Phone submission error:", error); + toast.error("Failed to send OTP. Please try again."); } finally { setIsLoading(false); } @@ -165,21 +193,21 @@ export default function PhoneVerificationModal({ const handleOtpSubmit = useCallback(async () => { if (!otpCode.trim() || otpCode.length !== 6) { - toast.error('Please enter the 6-digit OTP code'); + toast.error("Please enter the 6-digit OTP code"); return; } setIsLoading(true); try { - const response = await fetch('/api/phone/verify-otp', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const response = await fetch("/api/phone/verify-otp", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phoneNumber: formattedPhone, otpCode: otpCode, - walletAddress: walletAddress - }) + walletAddress: walletAddress, + }), }); const data = await response.json(); @@ -187,14 +215,14 @@ export default function PhoneVerificationModal({ if (data.success) { setStep(STEPS.VERIFIED); } else { - toast.error(data.error || 'Invalid OTP code'); + toast.error(data.error || "Invalid OTP code"); if (data.attemptsRemaining !== undefined) { setAttemptsRemaining(data.attemptsRemaining); } } } catch (error) { - console.error('OTP verification error:', error); - toast.error('Failed to verify OTP. Please try again.'); + console.error("OTP verification error:", error); + toast.error("Failed to verify OTP. Please try again."); } finally { setIsLoading(false); } @@ -203,25 +231,25 @@ export default function PhoneVerificationModal({ const handleResendOtp = useCallback(async () => { setIsLoading(true); try { - const response = await fetch('/api/phone/send-otp', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const response = await fetch("/api/phone/send-otp", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phoneNumber: formattedPhone, - walletAddress: walletAddress - }) + walletAddress: walletAddress, + }), }); const data = await response.json(); if (data.success) { setAttemptsRemaining(3); - setOtpCode(''); - toast.success('New OTP sent successfully'); + setOtpCode(""); + toast.success("New OTP sent successfully"); } else { - toast.error(data.error || 'Failed to resend OTP'); + toast.error(data.error || "Failed to resend OTP"); } } catch (error) { - toast.error('Failed to resend OTP'); + toast.error("Failed to resend OTP"); } finally { setIsLoading(false); } @@ -231,150 +259,173 @@ export default function PhoneVerificationModal({ onClose(); // Reset state when modal is closed setStep(STEPS.ENTER_PHONE); - setPhoneNumber(''); - setFormattedPhone(''); - setOtpCode(''); + setPhoneNumber(""); + setFormattedPhone(""); + setOtpCode(""); setAttemptsRemaining(3); setIsCountryDropdownOpen(false); - setCountrySearch(''); + setCountrySearch(""); }; const renderEnterPhone = () => ( -
    +
    Verify your number to start swapping

    - Enter your fullname & phone number to unlock your first swaps on Noblocks. No extra documents required. + Enter your fullname & phone number to unlock your first swaps on + Noblocks. No extra documents required.

    -
    -
    - - setName(e.target.value)} - placeholder="Enter your fullname" - className="min-h-12 w-full rounded-xl border border-border-input bg-transparent py-3 px-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-black2 dark:placeholder:text-white/30 dark:focus-within:border-white/40 text-neutral-900 dark:text-white/80" - /> -
    +
    +
    + + setName(e.target.value)} + placeholder="Enter your fullname" + className="min-h-12 w-full rounded-xl border border-border-input bg-transparent px-4 py-3 text-sm text-neutral-900 transition-all placeholder:text-text-placeholder focus-within:border-gray-400 focus:outline-none disabled:cursor-not-allowed dark:border-white/20 dark:bg-black2 dark:text-white/80 dark:placeholder:text-white/30 dark:focus-within:border-white/40" + /> +
    -
    - -
    -
    - - - {isCountryDropdownOpen && ( -
    -
    - setCountrySearch(e.target.value)} - className="w-full px-3 py-2 text-sm bg-transparent border border-border-input dark:border-white/20 rounded-lg focus:outline-none focus:border-gray-400 dark:focus:border-white/40 text-neutral-900 dark:text-white/80 placeholder:text-text-placeholder dark:placeholder:text-white/30" - autoFocus - /> -
    - -
    - {isLoadingCountries ? ( -
    -
    Loading countries...
    -
    - ) : filteredCountries.length > 0 ? ( - filteredCountries.map((country) => ( - + + {isCountryDropdownOpen && ( +
    +
    + setCountrySearch(e.target.value)} + className="w-full rounded-lg border border-border-input bg-transparent px-3 py-2 text-sm text-neutral-900 placeholder:text-text-placeholder focus:border-gray-400 focus:outline-none dark:border-white/20 dark:text-white/80 dark:placeholder:text-white/30 dark:focus:border-white/40" + autoFocus + /> +
    + +
    + {isLoadingCountries ? ( +
    +
    + Loading countries... +
    +
    + ) : filteredCountries.length > 0 ? ( + filteredCountries.map((country) => ( + - )) - ) : ( -
    -
    No countries found
    -
    - )} + className={`flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-gray-50 dark:hover:bg-white/5 ${ + selectedCountry.country === country.country + ? "bg-gray-50 dark:bg-white/5" + : "" + }`} + > + {`${country.name} { + e.currentTarget.style.display = "none"; + }} + /> + + {country.code} + + + {country.name} + + + )) + ) : ( +
    +
    + No countries found +
    +
    + )} +
    -
    - )} + )} +
    + + setPhoneNumber(e.target.value)} + placeholder="enter your phone number" + className="min-h-12 w-full rounded-xl border border-border-input bg-transparent py-3 pl-24 pr-4 text-sm text-neutral-900 transition-all placeholder:text-text-placeholder focus-within:border-gray-400 focus:outline-none disabled:cursor-not-allowed dark:border-white/20 dark:bg-black2 dark:text-white/80 dark:placeholder:text-white/30 dark:focus-within:border-white/40" + style={{ + paddingLeft: `${selectedCountry.code.length * 8 + 60}px`, + }} + />
    - - setPhoneNumber(e.target.value)} - placeholder="enter your phone number" - className="min-h-12 w-full rounded-xl border border-border-input bg-transparent py-3 pl-24 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-black2 dark:placeholder:text-white/30 dark:focus-within:border-white/40 text-neutral-900 dark:text-white/80" - style={{ paddingLeft: `${selectedCountry.code.length * 8 + 60}px` }} - />
    -
    {/* Info message */}
    - -

    -By clicking "Verify and start", you consent to recieving transactional text messages for notifications and alerts from Noblocks. Reply STOP to opt out. you agree to our Privacy Policy and terms & conditions.. + +

    + By clicking "Verify and start", you consent to recieving transactional + text messages for notifications and alerts from Noblocks. Reply STOP + to opt out. you agree to our{" "} + + Privacy Policy and terms & conditions. + + .

    @@ -384,59 +435,67 @@ By clicking "Verify and start", you consent to recieving transactional text mess disabled={isLoading || !phoneNumber.trim()} className={`${primaryBtnClasses} w-full`} > - {isLoading ? 'Sending...' : 'Verify and start'} + {isLoading ? "Sending..." : "Verify and start"} ); const renderEnterOtp = () => ( -
    - setStep(STEPS.ENTER_PHONE)} /> +
    + setStep(STEPS.ENTER_PHONE)} + /> Enter the code we texted you

    - We sent a 6-digit code to {formattedPhone.replace(/(\+\d+\s+\d{3})[\s\d]+(\d{2})/, '$1**$2')} to verify your number. + We sent a 6-digit code to{" "} + {formattedPhone.replace(/(\+\d+\s+\d{3})[\s\d]+(\d{2})/, "$1**$2")} to + verify your number.

    {/* OTP Input */} -
    +
    -
    +
    {[...Array(6)].map((_, index) => ( { - const value = e.target.value.replace(/\D/g, ''); + const value = e.target.value.replace(/\D/g, ""); if (value.length <= 1) { - const newOtp = otpCode.split(''); + const newOtp = otpCode.split(""); newOtp[index] = value; - setOtpCode(newOtp.join('')); - + setOtpCode(newOtp.join("")); + if (value && index < 5) { - const nextInput = e.target.parentElement?.children[index + 1] as HTMLInputElement; + const nextInput = e.target.parentElement?.children[ + index + 1 + ] as HTMLInputElement; nextInput?.focus(); } } }} onKeyDown={(e) => { // Handle backspace to move to previous input - if (e.key === 'Backspace' && !otpCode[index] && index > 0) { - const prevInput = (e.target as HTMLInputElement).parentElement?.children[index - 1] as HTMLInputElement; + if (e.key === "Backspace" && !otpCode[index] && index > 0) { + const prevInput = (e.target as HTMLInputElement).parentElement + ?.children[index - 1] as HTMLInputElement; prevInput?.focus(); } }} - className="h-[48px] w-[44px] rounded-2xl bg-transparent dark:bg-surface-overlay text-center text-lg font-medium transition-all focus-within:border-lavender-600 focus:outline-none dark:focus-within:border dark:focus-within:border-lavender-600 text-neutral-900 dark:text-lavender-600" + className="h-[48px] w-[44px] rounded-2xl bg-transparent text-center text-lg font-medium text-neutral-900 transition-all focus-within:border-lavender-600 focus:outline-none dark:bg-surface-overlay dark:text-lavender-600 dark:focus-within:border dark:focus-within:border-lavender-600" /> ))}
    @@ -451,17 +510,17 @@ By clicking "Verify and start", you consent to recieving transactional text mess )} -
    - Didn't receive a code?{' '} - -
    +
    + Didn't receive a code?{" "} + +
    @@ -478,7 +537,7 @@ By clicking "Verify and start", you consent to recieving transactional text mess disabled={isLoading || otpCode.length !== 6} className={`${primaryBtnClasses} w-full`} > - {isLoading ? 'Verifying...' : 'Continue'} + {isLoading ? "Verifying..." : "Continue"}
    @@ -486,15 +545,15 @@ By clicking "Verify and start", you consent to recieving transactional text mess const renderVerified = () => ( - + -
    -

    +

    Phone number verification successful!

    - You can now start converting your crypto to fiats at zero fees on noblocks + You can now start converting your crypto to fiats at zero fees on + noblocks

    @@ -505,12 +564,12 @@ By clicking "Verify and start", you consent to recieving transactional text mess onClose(); // Reset state for next use setStep(STEPS.ENTER_PHONE); - setPhoneNumber(''); - setFormattedPhone(''); - setOtpCode(''); + setPhoneNumber(""); + setFormattedPhone(""); + setOtpCode(""); setAttemptsRemaining(3); setIsCountryDropdownOpen(false); - setCountrySearch(''); + setCountrySearch(""); }} className={`${primaryBtnClasses} w-full`} > @@ -521,11 +580,13 @@ By clicking "Verify and start", you consent to recieving transactional text mess return ( - ); -} \ No newline at end of file +} diff --git a/app/components/ProfileDrawer.tsx b/app/components/ProfileDrawer.tsx index 876f9b88..af887c1d 100644 --- a/app/components/ProfileDrawer.tsx +++ b/app/components/ProfileDrawer.tsx @@ -1,376 +1,427 @@ "use client"; -import { useState, useEffect, useRef } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { toast } from 'sonner'; +import { useState, useEffect, useRef } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { toast } from "sonner"; import { Dialog } from "@headlessui/react"; import { - ArrowRight03Icon, - ArrowDown01Icon, - Copy01Icon, - StarIcon, - InformationCircleIcon, - FaceIdIcon, - CallingIcon -} from 'hugeicons-react'; + ArrowRight03Icon, + ArrowDown01Icon, + Copy01Icon, + StarIcon, + InformationCircleIcon, + FaceIdIcon, + CallingIcon, +} from "hugeicons-react"; import { usePrivy, useLinkAccount } from "@privy-io/react-auth"; -import { useKYCStatus, KYC_TIERS } from '../hooks/useKYCStatus'; -import { formatNumberWithCommas, shortenAddress, classNames } from '../utils'; -import { sidebarAnimation } from './AnimatedComponents'; +import { useKYCStatus, KYC_TIERS } from "../hooks/useKYCStatus"; +import { formatNumberWithCommas, shortenAddress, classNames } from "../utils"; +import { sidebarAnimation } from "./AnimatedComponents"; import { PiCheck } from "react-icons/pi"; -import { TbIdBadge } from 'react-icons/tb'; -import TransactionLimitModal from './TransactionLimitModal'; +import { TbIdBadge } from "react-icons/tb"; +import TransactionLimitModal from "./TransactionLimitModal"; interface ProfileDrawerProps { - isOpen: boolean; - onClose: () => void; + isOpen: boolean; + onClose: () => void; } export default function ProfileDrawer({ isOpen, onClose }: ProfileDrawerProps) { - const { user } = usePrivy(); - const { - tier, - isFullyVerified, - transactionSummary, - getCurrentLimits, - refreshStatus - } = useKYCStatus(); - - const [isLimitModalOpen, setIsLimitModalOpen] = useState(false); - const [expandedTiers, setExpandedTiers] = useState>({}); - const [isAddressCopied, setIsAddressCopied] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - const currentLimits = getCurrentLimits(); - const monthlyProgress = (transactionSummary.monthlySpent / currentLimits.monthly) * 100; - - // Refresh KYC status only if last refresh was >30s ago - const lastRefreshRef = useRef(0); - useEffect(() => { - if (isOpen) { - const now = Date.now(); - if (now - lastRefreshRef.current > 30000) { - setIsLoading(true); - refreshStatus().finally(() => setIsLoading(false)); - lastRefreshRef.current = now; - } - } - }, [isOpen, refreshStatus]); - - const walletAddress = user?.linkedAccounts.find((account) => account.type === "smart_wallet")?.address; - - const { linkEmail } = useLinkAccount({ - onSuccess: ({ user }) => { - toast.success(`${user.email} linked successfully`); - }, - onError: () => { - toast.error("Error linking account", { - description: "You might have this email linked already", - }); - }, - }); - - const handleCopyAddress = () => { - if (walletAddress) { - navigator.clipboard.writeText(walletAddress); - setIsAddressCopied(true); - toast.success("Address copied to clipboard"); - setTimeout(() => setIsAddressCopied(false), 2000); - } - }; - - const toggleTierExpansion = (tierLevel: number) => { - setExpandedTiers(prev => ({ - ...prev, - [tierLevel]: !prev[tierLevel] - })); - }; - - // Auto-expand next tier section - useEffect(() => { - if (tier < 1) { - setExpandedTiers(prev => ({ - ...prev, - [tier + 1]: true - })); - } - }, [tier]); + const { user } = usePrivy(); + const { + tier, + isFullyVerified, + transactionSummary, + getCurrentLimits, + refreshStatus, + } = useKYCStatus(); + + const [isLimitModalOpen, setIsLimitModalOpen] = useState(false); + const [expandedTiers, setExpandedTiers] = useState>( + {}, + ); + const [isAddressCopied, setIsAddressCopied] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const currentLimits = getCurrentLimits(); + const monthlyLimit = currentLimits.monthly || 0; + const monthlyProgress = + monthlyLimit > 0 + ? (transactionSummary.monthlySpent / monthlyLimit) * 100 + : 0; + + // Refresh KYC status only if last refresh was >30s ago + const lastRefreshRef = useRef(0); + useEffect(() => { + if (isOpen) { + const now = Date.now(); + if (now - lastRefreshRef.current > 30000) { + setIsLoading(true); + refreshStatus().finally(() => setIsLoading(false)); + lastRefreshRef.current = now; + } + } + }, [isOpen, refreshStatus]); + + const walletAddress = user?.linkedAccounts.find( + (account) => account.type === "smart_wallet", + )?.address; + + const { linkEmail } = useLinkAccount({ + onSuccess: ({ user }) => { + toast.success(`${user.email?.address} linked successfully`); + }, + onError: () => { + toast.error("Error linking account", { + description: "You might have this email linked already", + }); + }, + }); + + const handleCopyAddress = () => { + if (walletAddress) { + navigator.clipboard.writeText(walletAddress); + setIsAddressCopied(true); + toast.success("Address copied to clipboard"); + setTimeout(() => setIsAddressCopied(false), 2000); + } + }; + + const toggleTierExpansion = (tierLevel: number) => { + setExpandedTiers((prev) => ({ + ...prev, + [tierLevel]: !prev[tierLevel], + })); + }; + + // Auto-expand next tier section + useEffect(() => { + if (tier < 1) { + setExpandedTiers((prev) => ({ + ...prev, + [tier + 1]: true, + })); + } + }, [tier]); + + const renderSkeletonLoader = () => ( +
    + {/* Account card skeleton */} +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + {/* Current tier skeleton */} +
    +
    +
    +
    +
    +
    +
    +
    +
    + + {/* Tier sections skeleton */} + {[1, 2].map((i) => ( +
    +
    +
    + ))} +
    + ); - const renderSkeletonLoader = () => ( -
    - {/* Account card skeleton */} -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    + const renderTierSection = (tierLevel: number) => { + const tierData = KYC_TIERS[tierLevel]; + const isExpanded = expandedTiers[tierLevel]; - {/* Current tier skeleton */} -
    -
    -
    -
    -
    -
    -
    -
    -
    + if (!tierData) return null; - {/* Tier sections skeleton */} - {[1, 2].map((i) => ( -
    -
    + return ( +
    + + + + {isExpanded && ( + +
    +
      + {tierData.requirements.map((req, index) => ( +
    • +
      + {req.includes("number") ? ( + + ) : req.includes("verification") ? ( + + ) : ( + req.includes("ID") && ( + + ) + )} +
      + {req} +
    • + ))} +
    +
    +

    + Limit +

    +

    + + ${formatNumberWithCommas(tierData.limits.monthly)} + {" "} + / month +

    - ))} -
    + {tier == 0 && tierLevel === tier + 1 && ( + + )} +
    + + )} + +
    ); + }; + + return ( + <> + + {isOpen && ( + +
    + {/* Backdrop overlay */} + + + {/* Drawer content */} + +
    + {/* Header with close button */} +
    +

    + Profile +

    + +
    + +
    + {isLoading ? ( + renderSkeletonLoader() + ) : ( + <> + {/* Account info card */} +
    +

    + Account +

    + + {/* Email Connection */} +
    + +
    + {user?.email ? ( +
    +

    + {user.email.address} +

    +
    + ) : ( + + )} + + {/* Wallet Address */} +
    +

    + {shortenAddress(walletAddress ?? "", 10)} +

    + +
    +
    +
    +
    - const renderTierSection = (tierLevel: number) => { - const tierData = KYC_TIERS[tierLevel]; - const isExpanded = expandedTiers[tierLevel]; + {/* Current Tier Status */} + {tier >= 1 && tier !== undefined && ( +
    + {/* Current Tier Badge */} + +
    + + + Current: {KYC_TIERS[tier]?.name || "Tier 0"} + +
    - if (!tierData) return null; + {/* Monthly Limit Progress */} +
    +
    + + Monthly limit + + +
    + +
    + $ + {formatNumberWithCommas( + transactionSummary.monthlySpent, + )}{" "} + / $ + {formatNumberWithCommas(currentLimits.monthly)} +
    + + {/* Progress Bar */} +
    +
    +
    +
    - return ( -
    - )} - /> -
    - +
    + )} - - {isExpanded && ( - -
    -
      - {tierData.requirements.map((req, index) => ( -
    • -
      - {req.includes('number') ? ( - - ) : req.includes('verification') ? ( - - ) : req.includes('ID') && ( - - )} -
      - {req} -
    • - ))} -
    -
    -

    Limit

    -

    ${formatNumberWithCommas(tierData.limits.monthly)} / month

    -
    - {tier == 0 && tierLevel === tier + 1 && ( - - )} + {/* Tier Information */} + + {Object.values(KYC_TIERS) + .filter((tierData) => tierData.level > tier) // Only show tiers above current + .map((tierData) => ( +
    + {renderTierSection(tierData.level)}
    - + ))} + )} - +
    +
    +
    - ); - }; - - return ( - <> - - {isOpen && ( - -
    - {/* Backdrop overlay */} - - - {/* Drawer content */} - -
    - {/* Header with close button */} -
    -

    - Profile -

    - -
    - -
    - {isLoading ? renderSkeletonLoader() : ( - <> - {/* Account info card */} -
    -

    Account

    - - {/* Email Connection */} -
    - -
    - {user?.email ? ( -
    -

    - {user.email.address} -

    -
    - ) : ( - - )} - - {/* Wallet Address */} -
    -

    - {shortenAddress(walletAddress ?? "", 10)} -

    - -
    -
    -
    -
    - - - {/* Current Tier Status */} - {tier >= 1 && tier !== undefined && ( -
    - {/* Current Tier Badge */} - -
    - - - Current: {KYC_TIERS[tier]?.name || 'Tier 0'} - -
    - - {/* Monthly Limit Progress */} -
    -
    - - Monthly limit - - -
    - -
    - ${formatNumberWithCommas(transactionSummary.monthlySpent)} / ${formatNumberWithCommas(currentLimits.monthly)} -
    - - {/* Progress Bar */} -
    -
    -
    -
    - - {/* Upgrade Button */} - {tier < 2 && !isFullyVerified && ( - - )} -
    - )} - - {/* Tier Information */} - - {Object.values(KYC_TIERS) - .filter(tierData => tierData.level > tier) // Only show tiers above current - .map(tierData =>
    {renderTierSection(tierData.level)}
    )} - - )} -
    -
    - -
    -
    - )} -
    - - { - setIsLimitModalOpen(false); - await refreshStatus(); - }} - /> - - ); -} \ No newline at end of file +
    + )} +
    + + { + setIsLimitModalOpen(false); + await refreshStatus(); + }} + /> + + ); +} diff --git a/app/components/TransactionLimitModal.tsx b/app/components/TransactionLimitModal.tsx index 26a21f49..24046f47 100644 --- a/app/components/TransactionLimitModal.tsx +++ b/app/components/TransactionLimitModal.tsx @@ -1,217 +1,251 @@ "use client"; -import { useEffect, useState } from 'react'; -import { AnimatePresence, motion } from 'framer-motion'; +import { useEffect, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; import { - InformationSquareIcon, - Wallet02Icon, - StarIcon, - InformationCircleIcon -} from 'hugeicons-react'; -import { useKYCStatus, KYC_TIERS } from '../hooks/useKYCStatus'; -import PhoneVerificationModal from './PhoneVerificationModal'; -import { primaryBtnClasses, secondaryBtnClasses } from './Styles'; -import { AnimatedModal, fadeInOut } from './AnimatedComponents'; -import { formatNumberWithCommas } from '../utils'; -import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react'; -import { KycModal } from './KycModal'; + InformationSquareIcon, + Wallet02Icon, + StarIcon, + InformationCircleIcon, +} from "hugeicons-react"; +import { useKYCStatus, KYC_TIERS } from "../hooks/useKYCStatus"; +import PhoneVerificationModal from "./PhoneVerificationModal"; +import { primaryBtnClasses, secondaryBtnClasses } from "./Styles"; +import { AnimatedModal, fadeInOut } from "./AnimatedComponents"; +import { formatNumberWithCommas } from "../utils"; +import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react"; +import { KycModal } from "./KycModal"; interface TransactionLimitModalProps { - isOpen: boolean; - onClose: () => void; - transactionAmount?: number; + isOpen: boolean; + onClose: () => void; + transactionAmount?: number; } export default function TransactionLimitModal({ - isOpen, - onClose, - transactionAmount = 0 + isOpen, + onClose, + transactionAmount = 0, }: TransactionLimitModalProps) { - const { - tier, - getCurrentLimits, - refreshStatus, - phoneNumber, - transactionSummary - } = useKYCStatus(); - - const [isPhoneModalOpen, setIsPhoneModalOpen] = useState(false); - const [isKycModalOpen, setIsKycModalOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - // Refresh KYC status every time modal is opened - useEffect(() => { - if (isOpen) { - setIsLoading(true); - refreshStatus().finally(() => setIsLoading(false)); - } - }, [isOpen, refreshStatus]); - - // Auto-open phone verification modal if tier is less than 1 (unverified) - useEffect(() => { - if (isOpen && !isLoading && ((tier < 1 && !phoneNumber) || tier === undefined)) { - setIsPhoneModalOpen(true); - } - }, [isOpen, isLoading, tier]); - - const currentLimits = getCurrentLimits(); - const currentTier = KYC_TIERS[tier]; - const nextTier = KYC_TIERS[tier + 1]; - - const handlePhoneVerified = async (phoneNumber: string) => { - setIsPhoneModalOpen(false); - onClose(); - }; - - const renderLoadingStatus = () => ( - -
    -
    - ); - - const renderMainContent = () => ( - -
    - - - Increase your transaction limit - -

    - Your current monthly limit is ${formatNumberWithCommas(currentLimits.monthly)}. Verify your identity to unlock higher limits. -

    -
    - -
    - - -
    - - - Current: {tier < 1 ? 'Tier 0' : currentTier?.name || 'Tier 0'} - -
    - - {/* Monthly Limit Progress */} -
    -
    - - Monthly limit - - -
    - - {/* Progress Display */} -
    -
    - ${formatNumberWithCommas(transactionSummary.monthlySpent)} / ${formatNumberWithCommas(currentLimits.monthly)} -
    - - {/* Progress Bar */} -
    -
    0 - ? `${Math.min( - (transactionSummary.monthlySpent / currentLimits.monthly) * 100, - 100, - )}%` - : "0%", - }} - /> -
    -
    -
    - - -
    - {/* Info Text */} -
    - -

    - {tier < 1 ? ( - <>Tier 0 gives you ${formatNumberWithCommas(currentLimits.monthly)}/month. Verify your ID to unlock ${nextTier ? formatNumberWithCommas(nextTier.limits.monthly) : '1,000'}/month and beyond. Learn more. - ) : ( - <>You're currently at {currentTier?.name} with ${formatNumberWithCommas(currentLimits.monthly)}/month. {nextTier ? `Upgrade to ${nextTier.name} for ${formatNumberWithCommas(nextTier.limits.monthly)}/month` : 'You have the highest tier available'}. Learn more. - )} -

    + const { + tier, + getCurrentLimits, + refreshStatus, + phoneNumber, + transactionSummary, + } = useKYCStatus(); + + const [isPhoneModalOpen, setIsPhoneModalOpen] = useState(false); + const [isKycModalOpen, setIsKycModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // Refresh KYC status every time modal is opened + useEffect(() => { + if (isOpen) { + setIsLoading(true); + refreshStatus().finally(() => setIsLoading(false)); + } + }, [isOpen, refreshStatus]); + + // Auto-open phone verification modal if tier is less than 1 (unverified) + useEffect(() => { + if ( + isOpen && + !isLoading && + ((tier < 1 && !phoneNumber) || tier === undefined) + ) { + setIsPhoneModalOpen(true); + } + }, [isOpen, isLoading, tier, phoneNumber]); + + const currentLimits = getCurrentLimits(); + const currentTier = KYC_TIERS[tier]; + const nextTier = KYC_TIERS[tier + 1]; + + const handlePhoneVerified = async (phoneNumber: string) => { + setIsPhoneModalOpen(false); + onClose(); + }; + + const renderLoadingStatus = () => ( + +
    +
    + ); + + const renderMainContent = () => ( + +
    + + + Increase your transaction limit + +

    + Your current monthly limit is{" "} + + ${formatNumberWithCommas(currentLimits.monthly)} + + . Verify your identity to unlock higher limits. +

    +
    + +
    +
    + + + Current: {tier < 1 ? "Tier 0" : currentTier?.name || "Tier 0"} + +
    + + {/* Monthly Limit Progress */} +
    +
    + + Monthly limit + + +
    + + {/* Progress Display */} +
    +
    + ${formatNumberWithCommas(transactionSummary.monthlySpent)} / $ + {formatNumberWithCommas(currentLimits.monthly)}
    - {/* Action Button */} - {tier < 2 && ( - - )} - - {/* Already at max tier */} - {tier >= 2 && ( - - )} - - ); - - if (!isOpen) return null; - - return ( - <> - - {isOpen && !isPhoneModalOpen && ( - - - )} - - - { - setIsPhoneModalOpen(false); - onClose(); // Close parent modal too + {/* Progress Bar */} +
    +
    0 + ? `${Math.min( + (transactionSummary.monthlySpent / + currentLimits.monthly) * + 100, + 100, + )}%` + : "0%", }} - onVerified={handlePhoneVerified} + /> +
    +
    +
    +
    + {/* Info Text */} +
    + +

    + {tier < 1 ? ( + <> + Tier 0 gives you ${formatNumberWithCommas(currentLimits.monthly)} + /month. Verify your ID to unlock $ + {nextTier + ? formatNumberWithCommas(nextTier.limits.monthly) + : "1,000"} + /month and beyond.{" "} + + Learn more. + + + ) : ( + <> + You're currently at {currentTier?.name} with $ + {formatNumberWithCommas(currentLimits.monthly)}/month.{" "} + {nextTier + ? `Upgrade to ${nextTier.name} for ${formatNumberWithCommas(nextTier.limits.monthly)}/month` + : "You have the highest tier available"} + .{" "} + + Learn more. + + + )} +

    +
    + + {/* Action Button */} + {tier < 2 && ( + + )} + + {/* Already at max tier */} + {tier >= 2 && ( + + )} + + ); + + if (!isOpen) return null; + + return ( + <> + + {isOpen && !isPhoneModalOpen && ( + + + )} + + + { + setIsPhoneModalOpen(false); + onClose(); // Close parent modal too + }} + onVerified={handlePhoneVerified} + /> + + + {isKycModalOpen && ( + { + setIsKycModalOpen(false); + onClose(); + }} + > + { + await refreshStatus(); + setIsKycModalOpen(false); + onClose(); + }} + /> + + )} + + + ); } diff --git a/app/lib/phone-verification.ts b/app/lib/phone-verification.ts index cf7c0d43..f1f25bcd 100644 --- a/app/lib/phone-verification.ts +++ b/app/lib/phone-verification.ts @@ -1,21 +1,12 @@ -import { parsePhoneNumber, CountryCode } from 'libphonenumber-js'; -import twilio from 'twilio'; +import { parsePhoneNumber, CountryCode } from "libphonenumber-js"; +import twilio from "twilio"; // Initialize Twilio client const twilioClient = twilio( process.env.TWILIO_ACCOUNT_SID!, - process.env.TWILIO_AUTH_TOKEN! + process.env.TWILIO_AUTH_TOKEN!, ); -// African country codes that should use Termii -const AFRICAN_COUNTRIES: CountryCode[] = [ - 'NG', 'KE', 'GH', 'ZA', 'UG', 'TZ', 'EG', 'MA', 'DZ', 'AO', - 'MG', 'CM', 'CI', 'NE', 'BF', 'ML', 'MW', 'ZM', 'SN', 'SO', - 'TD', 'GN', 'RW', 'BJ', 'TN', 'BI', 'ER', 'SL', 'TG', 'LR', - 'LY', 'MR', 'GM', 'BW', 'GA', 'LS', 'GW', 'GQ', 'MU', - 'DJ', 'SZ', 'KM', 'CV', 'ST', 'SC', 'SS', 'CF', 'CD', 'CG' -]; - export interface PhoneVerificationResult { success: boolean; message: string; @@ -28,9 +19,9 @@ export interface PhoneValidation { country?: CountryCode; internationalFormat?: string; e164Format?: string; // E.164 format without spaces (e.g., +12025550123) - digitsOnly?: string; // Digits only format for Termii (e.g., 2025550123) - isAfrican: boolean; - provider: 'termii' | 'twilio'; + digitsOnly?: string; // Digits only format for KudiSMS (e.g., 2025550123) + isNigerian: boolean; + provider: "kudisms" | "twilio"; } /** @@ -38,7 +29,7 @@ export interface PhoneValidation { * Returns multiple formats for different use cases: * - internationalFormat: Display format with spaces (e.g., +1 202 555 0123) * - e164Format: Twilio-compatible format without spaces (e.g., +12025550123) - * - digitsOnly: Termii-compatible format (e.g., 12025550123) + * - digitsOnly: KudiSMS-compatible format (e.g., 12025550123) */ export function validatePhoneNumber(phoneNumber: string): PhoneValidation { try { @@ -47,77 +38,77 @@ export function validatePhoneNumber(phoneNumber: string): PhoneValidation { if (!parsed || !parsed.isValid()) { return { isValid: false, - isAfrican: false, - provider: 'twilio' + isNigerian: false, + provider: "twilio", }; } const country = parsed.country as CountryCode; - const isAfrican = AFRICAN_COUNTRIES.includes(country); + const isNigerian = country === "NG"; return { isValid: true, country, internationalFormat: parsed.formatInternational(), // With spaces for display - e164Format: parsed.format('E.164'), // Without spaces for Twilio - digitsOnly: parsed.number.toString().replace(/\D/g, ''), // Digits only for Termii - isAfrican, - provider: isAfrican ? 'termii' : 'twilio' + e164Format: parsed.format("E.164"), // Without spaces for Twilio + digitsOnly: parsed.number.toString().replace(/\D/g, ""), // Digits only for KudiSMS + isNigerian, + provider: isNigerian ? "kudisms" : "twilio", }; } catch (error) { - console.error('Error validating phone number:', error); + console.error("Error validating phone number:", error); return { isValid: false, - isAfrican: false, - provider: 'twilio' + isNigerian: false, + provider: "twilio", }; } } /** - * Sends OTP via Termii for African numbers + * Sends OTP via Kudi SMS for Nigerian numbers */ -export async function sendTermiiOTP( +export async function sendKudiSMSOTP( phoneNumber: string, - code: string + code: string, ): Promise { try { - const response = await fetch('https://v3.api.termii.com/api/sms/send', { - method: 'POST', + const response = await fetch("https://my.kudisms.net/api/otp", { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ - to: phoneNumber, - from: process.env.TERMII_SENDER_ID || 'Noblocks', - sms: `Your Noblocks verification code is: ${code}. This code expires in 5 minutes.`, - type: 'plain', - channel: 'generic', - api_key: process.env.TERMII_API_KEY, + recipients: phoneNumber, + senderID: process.env.KUDISMS_SENDER_ID || "Noblocks", + otp: code, + appnamecode: process.env.KUDISMS_APP_NAME_CODE, + templatecode: process.env.KUDISMS_TEMPLATE_CODE, + token: process.env.KUDISMS_API_KEY, }), }); const data = await response.json(); - if (data.message === 'Successfully Sent') { + if (data.status === "success") { return { success: true, - message: 'OTP sent successfully via Termii', - messageId: data.message_id, + message: data.message, + messageId: data.data, }; } else { return { success: false, - message: 'Failed to send OTP via Termii', - error: data.message || 'Unknown error', + message: "Failed to send OTP via KudiSMS", + error: data.message || "Unknown error", }; } } catch (error) { - console.error('Termii OTP error:', error); + console.error("KudiSMS OTP error:", error); return { success: false, - message: 'Failed to send OTP via Termii', - error: error instanceof Error ? error.message : 'Unknown error', + message: "Failed to send OTP via KudiSMS", + error: error instanceof Error ? error.message : "Unknown error", }; } } @@ -127,7 +118,7 @@ export async function sendTermiiOTP( */ export async function sendTwilioOTP( phoneNumber: string, - code: string + code: string, ): Promise { try { const message = await twilioClient.messages.create({ @@ -138,15 +129,15 @@ export async function sendTwilioOTP( return { success: true, - message: 'OTP sent successfully via Twilio', + message: "OTP sent successfully via Twilio", messageId: message.sid, }; } catch (error: any) { - console.error('Twilio OTP error:', error); + console.error("Twilio OTP error:", error); return { success: false, - message: 'Failed to send OTP via Twilio', - error: error.message || 'Unknown error', + message: "Failed to send OTP via Twilio", + error: error.message || "Unknown error", }; } } @@ -156,4 +147,4 @@ export async function sendTwilioOTP( */ export function generateOTP(): string { return Math.floor(100000 + Math.random() * 900000).toString(); -} \ No newline at end of file +} diff --git a/app/pages/TransactionForm.tsx b/app/pages/TransactionForm.tsx index bc192dda..df72a511 100644 --- a/app/pages/TransactionForm.tsx +++ b/app/pages/TransactionForm.tsx @@ -437,20 +437,20 @@ export const TransactionForm = ({ const handleSwap = () => { setOrderId(""); - + // Calculate the USD amount for transaction limit checking const formData = getValues(); - const usdAmount = formData.amountReceived || 0; - + const usdAmount = formData.amountSent || 0; + // Check transaction limits based on KYC tier const limitCheck = canTransact(usdAmount); - + if (!limitCheck.allowed) { setBlockedTransactionAmount(usdAmount); setIsLimitModalOpen(true); return; } - + // If limits are okay, proceed with transaction handleSubmit(onSubmit)(); }; @@ -838,7 +838,7 @@ export const TransactionForm = ({ { + onClose={async () => { setIsLimitModalOpen(false); await refreshStatus(); }} diff --git a/supabase/migrations/create_user_kyc_profiles.sql b/supabase/migrations/create_user_kyc_profiles.sql index 01ee9fbe..835bbacd 100644 --- a/supabase/migrations/create_user_kyc_profiles.sql +++ b/supabase/migrations/create_user_kyc_profiles.sql @@ -1,47 +1,62 @@ -- Create user_kyc_profiles table for managing user KYC and verification create table public.user_kyc_profiles ( - wallet_address text not null, - user_id text null, - phone_number text null, - email_address text null, - full_name text null, - date_of_birth date null, - id_type text null, - id_number text null, - id_country text null, - address_street text null, - address_city text null, - address_state text null, - address_country text null, - address_postal_code text null, - business_name text null, - platform jsonb null default '[]'::jsonb, - otp_code text null, - expires_at timestamp with time zone null, - provider text null, - attempts integer null default 0, - tier integer null default 0, - verified boolean null default false, - verified_at timestamp with time zone null, - created_at timestamp with time zone null default now(), - updated_at timestamp with time zone null default now(), - constraint user_kyc_profiles_pkey primary key (wallet_address), - constraint user_kyc_profiles_provider_check check ( - ( - provider = any (array['termii'::text, 'twilio'::text]) + wallet_address text not null, + user_id text null, + phone_number text null, + email_address text null, + full_name text null, + date_of_birth date null, + id_type text null, + id_number text null, + id_country text null, + address_street text null, + address_city text null, + address_state text null, + address_country text null, + address_postal_code text null, + business_name text null, + platform jsonb null default '[]'::jsonb, + otp_code text null, + expires_at timestamp with time zone null, + provider text null, + attempts integer null default 0, + tier integer null default 0, + verified boolean null default false, + verified_at timestamp with time zone null, + created_at timestamp with time zone null default now(), + updated_at timestamp with time zone null default now(), + constraint user_kyc_profiles_pkey primary key (wallet_address), + constraint user_kyc_profiles_provider_check check ( + ( + provider = any ( + array[ + 'kudisms'::text, + 'twilio'::text + ] + ) + ) + ), + constraint user_kyc_profiles_tier_check check ( + ( + tier = any (array[0, 1, 2, 3, 4]) + ) ) - ), - constraint user_kyc_profiles_tier_check check ((tier = any (array[0, 1, 2, 3, 4]))) ) TABLESPACE pg_default; -- Create indexes for faster lookups create index IF not exists idx_user_kyc_profiles_tier on public.user_kyc_profiles using btree (tier) TABLESPACE pg_default; + create index IF not exists idx_user_kyc_profiles_id_number on public.user_kyc_profiles using btree (id_number) TABLESPACE pg_default; + create index IF not exists idx_user_kyc_profiles_platform on public.user_kyc_profiles using gin (platform) TABLESPACE pg_default; + create index IF not exists idx_user_kyc_profiles_user_id on public.user_kyc_profiles using btree (user_id) TABLESPACE pg_default; + create index IF not exists idx_user_kyc_profiles_phone on public.user_kyc_profiles using btree (phone_number) TABLESPACE pg_default; + create index IF not exists idx_user_kyc_profiles_email on public.user_kyc_profiles using btree (email_address) TABLESPACE pg_default; + create index IF not exists idx_user_kyc_profiles_verified on public.user_kyc_profiles using btree (verified) TABLESPACE pg_default; create trigger update_user_kyc_profiles_updated_at BEFORE From 111dc836f87375ec122402214b1d089678e93455 Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Mon, 26 Jan 2026 15:47:10 +0100 Subject: [PATCH 16/22] feat: enhance phone verification by requiring full name and improve OTP generation method --- app/components/PhoneVerificationModal.tsx | 6 +++++- app/lib/phone-verification.ts | 3 ++- app/pages/TransactionForm.tsx | 8 ++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/components/PhoneVerificationModal.tsx b/app/components/PhoneVerificationModal.tsx index cf3a1f98..9ef3784f 100644 --- a/app/components/PhoneVerificationModal.tsx +++ b/app/components/PhoneVerificationModal.tsx @@ -134,6 +134,10 @@ export default function PhoneVerificationModal({ }, [isCountryDropdownOpen]); const handlePhoneSubmit = useCallback(async () => { + if (!name.trim()) { + toast.error("Please enter your full name"); + return; + } if (!phoneNumber.trim() || !walletAddress) { toast.error("Please enter a valid phone number"); return; @@ -432,7 +436,7 @@ export default function PhoneVerificationModal({
    - +
    {/* Recipient and memo */} {currency && (authenticated || isInjectedWallet) && - isPhoneVerified && ( + isUserVerified && ( (false); const [isOrderCreated, setIsOrderCreated] = useState(false); const [isSavingTransaction, setIsSavingTransaction] = useState(false); + + // Ref to prevent duplicate transaction saves + const isSavingTransactionRef = useRef(false); const searchParams = useSearchParams(); @@ -388,8 +391,11 @@ export const TransactionPreview = ({ orderId: string; txHash: `0x${string}`; }) => { - if (!embeddedWallet?.address || isSavingTransaction) return; + if (!embeddedWallet?.address || isSavingTransaction || isSavingTransactionRef.current) return; + + // Set both state and ref to prevent race conditions setIsSavingTransaction(true); + isSavingTransactionRef.current = true; try { const accessToken = await getAccessToken(); @@ -432,6 +438,8 @@ export const TransactionPreview = ({ // Don't show error toast as this is a background operation } finally { setIsSavingTransaction(false); + // Don't reset the ref here - keep it true to prevent any retry attempts + // isSavingTransactionRef.current = false; } }; @@ -444,7 +452,7 @@ export const TransactionPreview = ({ transport: http(getRpcUrl(selectedNetwork.chain.name)), }); - if (!publicClient || !activeWallet?.address || isOrderCreatedLogsFetched) + if (!publicClient || !activeWallet?.address || isOrderCreatedLogsFetched || isSavingTransactionRef.current) return; try { @@ -484,6 +492,7 @@ export const TransactionPreview = ({ }); setIsOrderCreatedLogsFetched(true); + isSavingTransactionRef.current = true; // Set ref immediately to prevent race condition clearInterval(intervalId); setOrderId(decodedLog.args.orderId); diff --git a/app/providers.tsx b/app/providers.tsx index 864591df..3754dafe 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -12,6 +12,7 @@ import config from "./lib/config"; import { BalanceProvider, InjectedWalletProvider, + KYCProvider, NetworkProvider, RocketStatusProvider, StepProvider, @@ -92,11 +93,13 @@ function ContextProviders({ children }: { children: ReactNode }) { - - - {children} - - + + + + {children} + + + diff --git a/middleware.ts b/middleware.ts index 7f6c1dc4..bf0e6b66 100644 --- a/middleware.ts +++ b/middleware.ts @@ -266,6 +266,8 @@ export const config = { "/api/blockfest/cashback", "/api/kyc/smile-id", "/api/kyc/status", + "/api/phone/send-otp", + "/api/phone/verify-otp", // (optional) add other instrumented API routes: // '/api/v1/kyc/:path*', '/api/v1/rates', '/api/v1/rates/:path*' ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81e07db8..a7b09645 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,7 +112,7 @@ importers: version: 15.5.7(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) next-sanity: specifier: ^10.0.4 - version: 10.0.10(@emotion/is-prop-valid@1.3.1)(@sanity/client@7.8.2)(@sanity/types@4.4.1(@types/react@19.0.8))(next@15.5.7(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react-is@19.1.1)(react@19.2.1)(sanity@4.4.1(@emotion/is-prop-valid@1.3.1)(@portabletext/sanity-bridge@1.1.2(@sanity/schema@4.4.1(@types/react@19.0.8)(debug@4.4.1))(@sanity/types@4.4.1(@types/react@19.0.8)(debug@4.4.1)))(@types/node@22.15.31)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(bufferutil@4.0.9)(immer@10.1.1)(jiti@2.5.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(styled-components@6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.1))(styled-components@6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.8.3) + version: 10.0.10(@emotion/is-prop-valid@1.3.1)(@sanity/client@7.8.2)(@sanity/types@4.4.1(@types/react@19.0.8))(next@15.5.7(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react-is@19.1.1)(react@19.2.1)(sanity@4.4.1(@emotion/is-prop-valid@1.3.1)(@portabletext/sanity-bridge@1.1.2(@sanity/schema@4.4.1(@types/react@19.0.8)(debug@4.4.1))(@sanity/types@4.4.1(@types/react@19.0.8)(debug@4.4.1)))(@types/node@22.15.31)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(bufferutil@4.0.9)(immer@10.1.1)(jiti@2.6.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(styled-components@6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.1))(styled-components@6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.8.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -139,7 +139,7 @@ importers: version: 3.0.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) sanity: specifier: ^4.4.1 - version: 4.4.1(@emotion/is-prop-valid@1.3.1)(@portabletext/sanity-bridge@1.1.2(@sanity/schema@4.4.1(@types/react@19.0.8)(debug@4.4.1))(@sanity/types@4.4.1(@types/react@19.0.8)(debug@4.4.1)))(@types/node@22.15.31)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(bufferutil@4.0.9)(immer@10.1.1)(jiti@2.5.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(styled-components@6.1.19(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.1) + version: 4.4.1(@emotion/is-prop-valid@1.3.1)(@portabletext/sanity-bridge@1.1.2(@sanity/schema@4.4.1(@types/react@19.0.8)(debug@4.4.1))(@sanity/types@4.4.1(@types/react@19.0.8)(debug@4.4.1)))(@types/node@22.15.31)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(bufferutil@4.0.9)(immer@10.1.1)(jiti@2.6.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(styled-components@6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.1) smile-identity-core: specifier: ^3.1.0 version: 3.1.0 @@ -200,10 +200,10 @@ importers: version: 19.0.3(@types/react@19.0.8) eslint: specifier: ^9.28.0 - version: 9.28.0(jiti@2.5.1) + version: 9.28.0(jiti@2.6.1) eslint-config-next: specifier: 15.1.6 - version: 15.1.6(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3) + version: 15.1.6(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) jest: specifier: ^29.7.0 version: 29.7.0(@types/node@22.15.31)(babel-plugin-macros@3.1.0) @@ -4071,8 +4071,6 @@ packages: resolution: {integrity: sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - '@smileid/web-components@11.0.3': - resolution: {integrity: sha512-bKL01ahiAa9pmD/Bp4y08qY2qt37ItNbZDG+FjJHgnPNJIdOJWO7o6w/uZwAB5Qeyw4Q7yoczwzV+v0rk+zA2Q==} '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -4082,6 +4080,9 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@smileid/web-components@11.0.3': + resolution: {integrity: sha512-bKL01ahiAa9pmD/Bp4y08qY2qt37ItNbZDG+FjJHgnPNJIdOJWO7o6w/uZwAB5Qeyw4Q7yoczwzV+v0rk+zA2Q==} + '@solana/buffer-layout@4.0.1': resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} engines: {node: '>=5.10'} @@ -7224,6 +7225,7 @@ packages: glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: @@ -8083,10 +8085,6 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true - jiti@2.5.1: - resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} - hasBin: true - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -10037,11 +10035,15 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} - scmp@2.1.0: - resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} + scheduler@0.25.0-rc-603e6108-20241029: + resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scmp@2.1.0: + resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} + scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} @@ -10200,9 +10202,6 @@ packages: slate@0.118.0: resolution: {integrity: sha512-XAHgaoN3IikTz83DlJWZWR7e4SjuRn1Ps6I717fL7yaITF7zhZm5z8zbU+TaPlHu4APCV6TCMIF33EZdW3GqfQ==} - smile-identity-core@3.1.0: - resolution: {integrity: sha512-ZTIhHEJQS40ubqB3gbSLS098TuH2loXXiLyDJLM5DQNtMJb8E6K+HEGn2ZRn5GKm0CKIZK35cArNTfiM1BACOQ==} - engines: {node: '>=v12.0.0'} slice-ansi@3.0.0: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} @@ -10211,6 +10210,10 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + smile-identity-core@3.1.0: + resolution: {integrity: sha512-ZTIhHEJQS40ubqB3gbSLS098TuH2loXXiLyDJLM5DQNtMJb8E6K+HEGn2ZRn5GKm0CKIZK35cArNTfiM1BACOQ==} + engines: {node: '>=v12.0.0'} + solady@0.0.180: resolution: {integrity: sha512-9QVCyMph+wk78Aq/GxtDAQg7dvNoVWx2dS2Zwf11XlwFKDZ+YJG2lrQsK9NEIth9NOebwjBXAYk4itdwOOE4aw==} @@ -10539,10 +10542,12 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me teeny-request@9.0.0: resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} @@ -11541,6 +11546,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} @@ -14085,9 +14091,9 @@ snapshots: '@esbuild/win32-x64@0.25.9': optional: true - '@eslint-community/eslint-utils@4.7.0(eslint@9.28.0(jiti@2.5.1))': + '@eslint-community/eslint-utils@4.7.0(eslint@9.28.0(jiti@2.6.1))': dependencies: - eslint: 9.28.0(jiti@2.5.1) + eslint: 9.28.0(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -17382,7 +17388,7 @@ snapshots: groq-js: 1.17.3 pkg-dir: 5.0.0 prettier: 3.5.3 - semver: 7.7.2 + semver: 7.7.3 validate-npm-package-name: 3.0.0 transitivePeerDependencies: - '@types/node' @@ -17695,12 +17701,12 @@ snapshots: get-folder-size: 5.0.0 groq-js: 1.17.3 inquirer: 12.9.2(@types/node@22.15.31) - jiti: 2.5.1 + jiti: 2.6.1 mime-types: 3.0.1 ora: 8.2.0 tar-stream: 3.1.7 - vite: 7.1.2(@types/node@22.15.31)(jiti@2.5.1)(yaml@2.8.1) - vite-tsconfig-paths: 5.1.4(typescript@5.8.3)(vite@7.1.2(@types/node@22.15.31)(jiti@2.5.1)(yaml@2.8.1)) + vite: 7.1.2(@types/node@22.15.31)(jiti@2.6.1)(yaml@2.8.1) + vite-tsconfig-paths: 5.1.4(typescript@5.8.3)(vite@7.1.2(@types/node@22.15.31)(jiti@2.6.1)(yaml@2.8.1)) ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) xdg-basedir: 5.1.0 transitivePeerDependencies: @@ -18050,6 +18056,16 @@ snapshots: '@simplewebauthn/types@9.0.1': {} + '@sinclair/typebox@0.27.8': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + '@smileid/web-components@11.0.3': dependencies: '@mediapipe/tasks-vision': 0.10.22-rc.20250304 @@ -18061,15 +18077,6 @@ snapshots: preact-router: 4.1.2(preact@10.26.9) signature_pad: 5.1.3 validate.js: 0.13.1 - '@sinclair/typebox@0.27.8': {} - - '@sinonjs/commons@3.0.1': - dependencies: - type-detect: 4.0.8 - - '@sinonjs/fake-timers@10.3.0': - dependencies: - '@sinonjs/commons': 3.0.1 '@solana/buffer-layout@4.0.1': dependencies: @@ -18878,15 +18885,15 @@ snapshots: '@types/node': 22.15.31 optional: true - '@typescript-eslint/eslint-plugin@8.34.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.34.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3) + '@typescript-eslint/parser': 8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) '@typescript-eslint/scope-manager': 8.34.0 - '@typescript-eslint/type-utils': 8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3) - '@typescript-eslint/utils': 8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3) + '@typescript-eslint/type-utils': 8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.34.0 - eslint: 9.28.0(jiti@2.5.1) + eslint: 9.28.0(jiti@2.6.1) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -18895,14 +18902,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3)': + '@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.34.0 '@typescript-eslint/types': 8.34.0 '@typescript-eslint/typescript-estree': 8.34.0(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.34.0 debug: 4.4.1(supports-color@8.1.1) - eslint: 9.28.0(jiti@2.5.1) + eslint: 9.28.0(jiti@2.6.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -18925,12 +18932,12 @@ snapshots: dependencies: typescript: 5.8.3 - '@typescript-eslint/type-utils@8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@typescript-eslint/typescript-estree': 8.34.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) debug: 4.4.1(supports-color@8.1.1) - eslint: 9.28.0(jiti@2.5.1) + eslint: 9.28.0(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -18954,13 +18961,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3)': + '@typescript-eslint/utils@8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.28.0(jiti@2.5.1)) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.28.0(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.34.0 '@typescript-eslint/types': 8.34.0 '@typescript-eslint/typescript-estree': 8.34.0(typescript@5.8.3) - eslint: 9.28.0(jiti@2.5.1) + eslint: 9.28.0(jiti@2.6.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -19067,7 +19074,7 @@ snapshots: react: 19.2.1 vidstack: 0.6.15 - '@vitejs/plugin-react@4.7.0(vite@7.1.2(@types/node@22.15.31)(jiti@2.5.1)(yaml@2.8.1))': + '@vitejs/plugin-react@4.7.0(vite@7.1.2(@types/node@22.15.31)(jiti@2.6.1)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.3 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.3) @@ -19075,7 +19082,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.1.2(@types/node@22.15.31)(jiti@2.5.1)(yaml@2.8.1) + vite: 7.1.2(@types/node@22.15.31)(jiti@2.6.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -22739,19 +22746,19 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-next@15.1.6(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3): + eslint-config-next@15.1.6(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3): dependencies: '@next/eslint-plugin-next': 15.1.6 '@rushstack/eslint-patch': 1.11.0 - '@typescript-eslint/eslint-plugin': 8.34.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3) - '@typescript-eslint/parser': 8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3) - eslint: 9.28.0(jiti@2.5.1) + '@typescript-eslint/eslint-plugin': 8.34.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/parser': 8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) + eslint: 9.28.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0(jiti@2.5.1)) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0(jiti@2.5.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.28.0(jiti@2.5.1)) - eslint-plugin-react: 7.37.5(eslint@9.28.0(jiti@2.5.1)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.28.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0(jiti@2.6.1)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.28.0(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.28.0(jiti@2.6.1)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.28.0(jiti@2.6.1)) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -22767,33 +22774,33 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0(jiti@2.5.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1(supports-color@8.1.1) - eslint: 9.28.0(jiti@2.5.1) + eslint: 9.28.0(jiti@2.6.1) get-tsconfig: 4.10.1 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.14 unrs-resolver: 1.9.0 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0(jiti@2.5.1)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0(jiti@2.5.1)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0(jiti@2.6.1)): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: - '@typescript-eslint/parser': 8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3) - eslint: 9.28.0(jiti@2.5.1) + '@typescript-eslint/parser': 8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) + eslint: 9.28.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0(jiti@2.5.1)): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -22802,9 +22809,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7(supports-color@8.1.1) doctrine: 2.1.0 - eslint: 9.28.0(jiti@2.5.1) + eslint: 9.28.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0(jiti@2.5.1)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -22816,13 +22823,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.34.0(eslint@9.28.0(jiti@2.5.1))(typescript@5.8.3) + '@typescript-eslint/parser': 8.34.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.28.0(jiti@2.5.1)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.28.0(jiti@2.6.1)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -22832,7 +22839,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.28.0(jiti@2.5.1) + eslint: 9.28.0(jiti@2.6.1) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -22841,11 +22848,11 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@5.2.0(eslint@9.28.0(jiti@2.5.1)): + eslint-plugin-react-hooks@5.2.0(eslint@9.28.0(jiti@2.6.1)): dependencies: - eslint: 9.28.0(jiti@2.5.1) + eslint: 9.28.0(jiti@2.6.1) - eslint-plugin-react@7.37.5(eslint@9.28.0(jiti@2.5.1)): + eslint-plugin-react@7.37.5(eslint@9.28.0(jiti@2.6.1)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -22853,7 +22860,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.28.0(jiti@2.5.1) + eslint: 9.28.0(jiti@2.6.1) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -22876,9 +22883,9 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.28.0(jiti@2.5.1): + eslint@9.28.0(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.28.0(jiti@2.5.1)) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.28.0(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.20.1 '@eslint/config-helpers': 0.2.3 @@ -22914,7 +22921,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.5.1 + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -24782,8 +24789,6 @@ snapshots: jiti@2.4.2: {} - jiti@2.5.1: {} - jiti@2.6.1: {} jose@4.15.9: {} @@ -25557,7 +25562,7 @@ snapshots: net@1.0.2: {} - next-sanity@10.0.10(@emotion/is-prop-valid@1.3.1)(@sanity/client@7.8.2)(@sanity/types@4.4.1(@types/react@19.0.8))(next@15.5.7(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react-is@19.1.1)(react@19.2.1)(sanity@4.4.1(@emotion/is-prop-valid@1.3.1)(@portabletext/sanity-bridge@1.1.2(@sanity/schema@4.4.1(@types/react@19.0.8)(debug@4.4.1))(@sanity/types@4.4.1(@types/react@19.0.8)(debug@4.4.1)))(@types/node@22.15.31)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(bufferutil@4.0.9)(immer@10.1.1)(jiti@2.5.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(styled-components@6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.1))(styled-components@6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.8.3): + next-sanity@10.0.10(@emotion/is-prop-valid@1.3.1)(@sanity/client@7.8.2)(@sanity/types@4.4.1(@types/react@19.0.8))(next@15.5.7(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react-is@19.1.1)(react@19.2.1)(sanity@4.4.1(@emotion/is-prop-valid@1.3.1)(@portabletext/sanity-bridge@1.1.2(@sanity/schema@4.4.1(@types/react@19.0.8)(debug@4.4.1))(@sanity/types@4.4.1(@types/react@19.0.8)(debug@4.4.1)))(@types/node@22.15.31)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(bufferutil@4.0.9)(immer@10.1.1)(jiti@2.6.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(styled-components@6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.1))(styled-components@6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.8.3): dependencies: '@portabletext/react': 3.2.1(react@19.2.1) '@sanity/client': 7.8.2(debug@4.4.1) @@ -25569,7 +25574,7 @@ snapshots: next: 15.5.7(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - sanity: 4.4.1(@emotion/is-prop-valid@1.3.1)(@portabletext/sanity-bridge@1.1.2(@sanity/schema@4.4.1(@types/react@19.0.8)(debug@4.4.1))(@sanity/types@4.4.1(@types/react@19.0.8)(debug@4.4.1)))(@types/node@22.15.31)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(bufferutil@4.0.9)(immer@10.1.1)(jiti@2.5.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(styled-components@6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.1) + sanity: 4.4.1(@emotion/is-prop-valid@1.3.1)(@portabletext/sanity-bridge@1.1.2(@sanity/schema@4.4.1(@types/react@19.0.8)(debug@4.4.1))(@sanity/types@4.4.1(@types/react@19.0.8)(debug@4.4.1)))(@types/node@22.15.31)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(bufferutil@4.0.9)(immer@10.1.1)(jiti@2.6.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(styled-components@6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.1) styled-components: 6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1) transitivePeerDependencies: - '@emotion/is-prop-valid' @@ -25676,7 +25681,7 @@ snapshots: dependencies: hosted-git-info: 4.1.0 is-core-module: 2.16.1 - semver: 7.7.2 + semver: 7.7.3 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -26977,7 +26982,7 @@ snapshots: safer-buffer@2.1.2: {} - sanity@4.4.1(@emotion/is-prop-valid@1.3.1)(@portabletext/sanity-bridge@1.1.2(@sanity/schema@4.4.1(@types/react@19.0.8)(debug@4.4.1))(@sanity/types@4.4.1(@types/react@19.0.8)(debug@4.4.1)))(@types/node@22.15.31)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(bufferutil@4.0.9)(immer@10.1.1)(jiti@2.5.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(styled-components@6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.1): + sanity@4.4.1(@emotion/is-prop-valid@1.3.1)(@portabletext/sanity-bridge@1.1.2(@sanity/schema@4.4.1(@types/react@19.0.8)(debug@4.4.1))(@sanity/types@4.4.1(@types/react@19.0.8)(debug@4.4.1)))(@types/node@22.15.31)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(bufferutil@4.0.9)(immer@10.1.1)(jiti@2.6.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(styled-components@6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.1): dependencies: '@dnd-kit/core': 6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@dnd-kit/modifiers': 6.0.1(@dnd-kit/core@6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) @@ -27029,7 +27034,7 @@ snapshots: '@types/tar-stream': 3.1.4 '@types/use-sync-external-store': 1.5.0 '@types/which': 3.0.4 - '@vitejs/plugin-react': 4.7.0(vite@7.1.2(@types/node@22.15.31)(jiti@2.5.1)(yaml@2.8.1)) + '@vitejs/plugin-react': 4.7.0(vite@7.1.2(@types/node@22.15.31)(jiti@2.6.1)(yaml@2.8.1)) '@xstate/react': 6.0.0(@types/react@19.0.8)(react@19.2.1)(xstate@5.20.2) archiver: 7.0.1 arrify: 2.0.1 @@ -27105,7 +27110,7 @@ snapshots: rxjs-mergemap-array: 0.1.0(rxjs@7.8.2) scroll-into-view-if-needed: 3.1.0 scrollmirror: 1.2.4 - semver: 7.7.2 + semver: 7.7.3 shallow-equals: 1.0.0 speakingurl: 14.0.1 styled-components: 6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -27118,7 +27123,7 @@ snapshots: use-hot-module-reload: 2.0.0(react@19.2.1) use-sync-external-store: 1.5.0(react@19.2.1) uuid: 11.1.0 - vite: 7.1.2(@types/node@22.15.31)(jiti@2.5.1)(yaml@2.8.1) + vite: 7.1.2(@types/node@22.15.31)(jiti@2.6.1)(yaml@2.8.1) which: 5.0.0 xstate: 5.20.2 yargs: 17.7.2 @@ -27150,9 +27155,12 @@ snapshots: dependencies: xmlchars: 2.2.0 - scmp@2.1.0: {} + scheduler@0.25.0-rc-603e6108-20241029: {} + scheduler@0.27.0: {} + scmp@2.1.0: {} + scroll-into-view-if-needed@3.1.0: dependencies: compute-scroll-into-view: 3.1.1 @@ -27351,12 +27359,6 @@ snapshots: immer: 10.1.1 tiny-warning: 1.0.3 - smile-identity-core@3.1.0: - dependencies: - axios: 1.9.0 - jszip: 3.10.1 - transitivePeerDependencies: - - debug slice-ansi@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -27369,6 +27371,13 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + smile-identity-core@3.1.0: + dependencies: + axios: 1.9.0 + jszip: 3.10.1 + transitivePeerDependencies: + - debug + solady@0.0.180: {} sonic-boom@2.8.0: @@ -28433,7 +28442,7 @@ snapshots: validate.js@0.13.1: {} - valtio@1.11.2(@types/react@19.0.8)(react@19.0.0): + valtio@1.11.2(@types/react@19.0.8)(react@19.2.1): dependencies: proxy-compare: 2.5.1 use-sync-external-store: 1.2.0(react@19.2.1) @@ -28670,13 +28679,13 @@ snapshots: - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@7.1.2(@types/node@22.15.31)(jiti@2.5.1)(yaml@2.8.1)): + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@7.1.2(@types/node@22.15.31)(jiti@2.6.1)(yaml@2.8.1)): dependencies: debug: 4.4.1(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.8.3) optionalDependencies: - vite: 7.1.2(@types/node@22.15.31)(jiti@2.5.1)(yaml@2.8.1) + vite: 7.1.2(@types/node@22.15.31)(jiti@2.6.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color - typescript @@ -28695,7 +28704,7 @@ snapshots: jiti: 2.4.2 yaml: 2.8.1 - vite@7.1.2(@types/node@22.15.31)(jiti@2.5.1)(yaml@2.8.1): + vite@7.1.2(@types/node@22.15.31)(jiti@2.6.1)(yaml@2.8.1): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -28706,7 +28715,7 @@ snapshots: optionalDependencies: '@types/node': 22.15.31 fsevents: 2.3.3 - jiti: 2.5.1 + jiti: 2.6.1 yaml: 2.8.1 vm-browserify@1.1.2: {} diff --git a/supabase/migrations/create_transactions_table.sql b/supabase/migrations/create_transactions_table.sql index 84bc0b45..8367a9ec 100644 --- a/supabase/migrations/create_transactions_table.sql +++ b/supabase/migrations/create_transactions_table.sql @@ -1,27 +1,40 @@ -- Drop existing table if needed (optional) -- DROP TABLE IF EXISTS transactions; - -- Create the transactions table with proper JSON support for recipient - -CREATE TABLE transactions ( id uuid DEFAULT gen_random_uuid() PRIMARY KEY, - wallet_address TEXT NOT NULL, - transaction_type TEXT NOT NULL CHECK (transaction_type IN ('swap', - 'transfer')), from_currency TEXT NOT NULL, - to_currency TEXT NOT NULL, - amount_sent NUMERIC NOT NULL, - amount_received NUMERIC NOT NULL, - fee NUMERIC NOT NULL, - recipient JSONB NOT NULL, -- Changed to JSONB to store structured recipient data - status TEXT NOT NULL CHECK (status IN ('pending', - 'completed', - 'failed')), tx_hash TEXT, time_spent TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL); +-- Create the transactions table with proper JSON support for recipient + +CREATE TABLE transactions ( + id uuid DEFAULT gen_random_uuid () PRIMARY KEY, + wallet_address TEXT NOT NULL, + transaction_type TEXT NOT NULL CHECK ( + transaction_type IN ('swap', 'transfer') + ), + from_currency TEXT NOT NULL, + to_currency TEXT NOT NULL, + amount_sent NUMERIC NOT NULL, + amount_received NUMERIC NOT NULL, + fee NUMERIC NOT NULL, + recipient JSONB NOT NULL, -- Changed to JSONB to store structured recipient data + status TEXT NOT NULL CHECK ( + status IN ( + 'pending', + 'fulfilling', + 'completed', + 'failed' + ) + ), + tx_hash TEXT, + time_spent TEXT, + network TEXT, + order_id TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone ('utc'::text, now()) NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone ('utc'::text, now()) NOT NULL +); -- Create indices -CREATE INDEX idx_transactions_wallet_address ON transactions(wallet_address); +CREATE INDEX idx_transactions_wallet_address ON transactions (wallet_address); - -CREATE INDEX idx_transactions_created_at ON transactions(created_at DESC); +CREATE INDEX idx_transactions_created_at ON transactions (created_at DESC); -- Enable RLS @@ -36,24 +49,37 @@ END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- Grant permissions -GRANT EXECUTE ON FUNCTION set_current_wallet_address(TEXT) TO service_role; +GRANT +EXECUTE ON FUNCTION set_current_wallet_address (TEXT) TO service_role; -GRANT EXECUTE ON FUNCTION set_current_wallet_address(TEXT) TO authenticated; +GRANT +EXECUTE ON FUNCTION set_current_wallet_address (TEXT) TO authenticated; -GRANT EXECUTE ON FUNCTION set_current_wallet_address(TEXT) TO anon; +GRANT EXECUTE ON FUNCTION set_current_wallet_address (TEXT) TO anon; -- RLS policies -CREATE POLICY "Users can read own transactions" ON transactions -FOR -SELECT USING (wallet_address = current_setting('app.current_wallet_address', TRUE)); - - -CREATE POLICY "Users can insert own transactions" ON transactions -FOR -INSERT WITH CHECK (wallet_address = current_setting('app.current_wallet_address', TRUE)); - +CREATE POLICY "Users can read own transactions" ON transactions FOR +SELECT USING ( + wallet_address = current_setting( + 'app.current_wallet_address', TRUE + ) + ); + +CREATE POLICY "Users can insert own transactions" ON transactions FOR INSERT +WITH + CHECK ( + wallet_address = current_setting( + 'app.current_wallet_address', + TRUE + ) + ); CREATE POLICY "Users can update own transactions" ON transactions -FOR -UPDATE USING (wallet_address = current_setting('app.current_wallet_address', TRUE)); +FOR UPDATE + USING ( + wallet_address = current_setting( + 'app.current_wallet_address', + TRUE + ) + ); \ No newline at end of file From 1a685549a0b60a4ff1153c938fff02f298cb35cd Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Fri, 6 Feb 2026 08:31:22 +0100 Subject: [PATCH 18/22] refactor(TransactionPreview): simplify transaction saving logic and remove unnecessary state ref --- app/pages/TransactionPreview.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/pages/TransactionPreview.tsx b/app/pages/TransactionPreview.tsx index 8fc1ba1b..5413ed63 100644 --- a/app/pages/TransactionPreview.tsx +++ b/app/pages/TransactionPreview.tsx @@ -98,7 +98,7 @@ export const TransactionPreview = ({ const [isGatewayApproved, setIsGatewayApproved] = useState(false); const [isOrderCreated, setIsOrderCreated] = useState(false); const [isSavingTransaction, setIsSavingTransaction] = useState(false); - + // Ref to prevent duplicate transaction saves const isSavingTransactionRef = useRef(false); @@ -391,11 +391,8 @@ export const TransactionPreview = ({ orderId: string; txHash: `0x${string}`; }) => { - if (!embeddedWallet?.address || isSavingTransaction || isSavingTransactionRef.current) return; - - // Set both state and ref to prevent race conditions + if (!embeddedWallet?.address || isSavingTransaction) return; setIsSavingTransaction(true); - isSavingTransactionRef.current = true; try { const accessToken = await getAccessToken(); @@ -438,8 +435,6 @@ export const TransactionPreview = ({ // Don't show error toast as this is a background operation } finally { setIsSavingTransaction(false); - // Don't reset the ref here - keep it true to prevent any retry attempts - // isSavingTransactionRef.current = false; } }; @@ -452,7 +447,12 @@ export const TransactionPreview = ({ transport: http(getRpcUrl(selectedNetwork.chain.name)), }); - if (!publicClient || !activeWallet?.address || isOrderCreatedLogsFetched || isSavingTransactionRef.current) + if ( + !publicClient || + !activeWallet?.address || + isOrderCreatedLogsFetched || + isSavingTransactionRef.current + ) return; try { From 46bbcb2e1bc1c715b7f64f6b3a70f0a8daebe4a7 Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Tue, 10 Feb 2026 14:51:35 +0100 Subject: [PATCH 19/22] feat(kyc): implement Tier 3 address verification process --- .env.example | 11 + app/api/kyc/status/route.ts | 4 +- app/api/kyc/tier3-verify/route.ts | 185 ++++++ app/components/KycModal.tsx | 584 ++++++++++++++++-- app/components/ProfileDrawer.tsx | 18 +- app/components/TransactionLimitModal.tsx | 8 +- .../kyc/DocumentRequirementsModal.tsx | 76 +++ app/components/kyc/index.ts | 1 + app/context/KYCContext.tsx | 26 +- app/lib/dojah.ts | 71 +++ app/pages/TransactionForm.tsx | 1 + middleware.ts | 1 + 12 files changed, 934 insertions(+), 52 deletions(-) create mode 100644 app/api/kyc/tier3-verify/route.ts create mode 100644 app/components/kyc/DocumentRequirementsModal.tsx create mode 100644 app/components/kyc/index.ts create mode 100644 app/lib/dojah.ts diff --git a/.env.example b/.env.example index 61c79656..92c38b9e 100644 --- a/.env.example +++ b/.env.example @@ -123,6 +123,17 @@ SMILE_IDENTITY_PARTNER_ID=your_partner_id_here SMILE_ID_CALLBACK_URL= #optional callback url SMILE_IDENTITY_SERVER=0 # 0 for sandbox, 1 for production +# ============================================================================= +# Dojah โ€“ Tier 3 address / proof-of-address verification +# ============================================================================= +# Get from: https://app.dojah.io/developers/configuration +DOJAH_APP_ID=6977612b48b1f4961adee48c +DOJAH_SECRET_KEY=test_sk_S71SCe2U5LRXFcUuyqXtWCEse +# Optional: use https://sandbox.dojah.io for testing +DOJAH_BASE_URL=https://sandbox.dojah.io +# Supabase Storage bucket for KYC document uploads (create in Supabase Dashboard โ†’ Storage) +KYC_DOCUMENTS_BUCKET=kyc-documents + # ============================================================================= # Campaign Management (BlockFest) # ============================================================================= diff --git a/app/api/kyc/status/route.ts b/app/api/kyc/status/route.ts index 4efb1303..c199fa7a 100644 --- a/app/api/kyc/status/route.ts +++ b/app/api/kyc/status/route.ts @@ -26,11 +26,11 @@ export async function GET(request: NextRequest) { .eq('wallet_address', walletAddress.toLowerCase()) .single(); - const tier: 0 | 1 | 2 = (kycProfile?.tier as 0 | 1 | 2) || 0; + const tier: 0 | 1 | 2 | 3 | 4 = (kycProfile?.tier as 0 | 1 | 2 | 3 | 4) || 0; const phoneNumber = kycProfile?.phone_number || null; const phoneVerified = kycProfile?.verified && phoneNumber ? true : false; - // Full KYC (SmileID) is verified if tier is 2 + // Full KYC (SmileID) is verified if tier is at least 2; Tier 3/4 add address verification const fullKYCVerified = tier >= 2; const responseTime = Date.now() - startTime; diff --git a/app/api/kyc/tier3-verify/route.ts b/app/api/kyc/tier3-verify/route.ts new file mode 100644 index 00000000..296148d8 --- /dev/null +++ b/app/api/kyc/tier3-verify/route.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from "next/server"; +import { supabaseAdmin } from "@/app/lib/supabase"; +import { + verifyUtilityBill, + isDojahVerificationSuccess, +} from "@/app/lib/dojah"; +import { rateLimit } from "@/app/lib/rate-limit"; + +const KYC_BUCKET = process.env.KYC_DOCUMENTS_BUCKET || "kyc-documents"; +const SIGNED_URL_EXPIRY_SEC = 3600; + +export async function POST(request: NextRequest) { + const rateLimitResult = await rateLimit(request); + if (!rateLimitResult.success) { + return NextResponse.json( + { status: "error", message: "Too many requests. Please try again later." }, + { status: 429 } + ); + } + + const walletAddress = request.headers.get("x-wallet-address")?.toLowerCase(); + if (!walletAddress) { + return NextResponse.json( + { status: "error", message: "Unauthorized" }, + { status: 401 } + ); + } + + try { + const formData = await request.formData(); + const file = formData.get("file") as File | null; + const countryCode = formData.get("countryCode") as string | null; + const documentType = formData.get("documentType") as string | null; + const houseNumber = formData.get("houseNumber") as string | null; + const streetAddress = formData.get("streetAddress") as string | null; + const county = formData.get("county") as string | null; + const postalCode = formData.get("postalCode") as string | null; + + if (!file || file.size === 0) { + return NextResponse.json( + { status: "error", message: "Document file is required" }, + { status: 400 } + ); + } + if (!countryCode?.trim()) { + return NextResponse.json( + { status: "error", message: "Country is required" }, + { status: 400 } + ); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const ext = file.name.split(".").pop() || "jpg"; + const path = `tier3/${walletAddress}/${Date.now()}.${ext}`; + + const { error: uploadError } = await supabaseAdmin.storage + .from(KYC_BUCKET) + .upload(path, buffer, { + contentType: file.type || "image/jpeg", + upsert: false, + }); + + if (uploadError) { + console.error("KYC document upload error:", uploadError); + return NextResponse.json( + { + status: "error", + message: + uploadError.message || + "Failed to upload document. Ensure the KYC storage bucket exists.", + }, + { status: 500 } + ); + } + + const { + data: { signedUrl }, + error: signError, + } = await supabaseAdmin.storage + .from(KYC_BUCKET) + .createSignedUrl(path, SIGNED_URL_EXPIRY_SEC); + + if (signError || !signedUrl) { + console.error("Signed URL creation error:", signError); + return NextResponse.json( + { + status: "error", + message: "Failed to generate document URL", + }, + { status: 500 } + ); + } + + const dojahResult = await verifyUtilityBill(signedUrl); + if (!isDojahVerificationSuccess(dojahResult)) { + const msg = + dojahResult?.entity?.result?.message || + "Document could not be verified as a valid proof of address."; + return NextResponse.json( + { status: "error", message: msg }, + { status: 400 } + ); + } + + const { data: existingProfile } = await supabaseAdmin + .from("user_kyc_profiles") + .select("platform") + .eq("wallet_address", walletAddress) + .single(); + + const existingPlatform = Array.isArray(existingProfile?.platform) + ? existingProfile.platform + : []; + const otherVerifications = existingPlatform.filter( + (p: { type: string }) => p.type !== "address" + ); + const updatedPlatform = [ + ...otherVerifications, + { + type: "address", + identifier: "dojah", + verified: true, + }, + ]; + + const updatePayload: Record = { + tier: 3, + verified: true, + verified_at: new Date().toISOString(), + platform: updatedPlatform, + address_country: countryCode, + address_postal_code: postalCode?.trim() || null, + updated_at: new Date().toISOString(), + }; + if (houseNumber?.trim()) + updatePayload.address_street = [houseNumber, streetAddress?.trim()] + .filter(Boolean) + .join(" "); + else if (streetAddress?.trim()) + updatePayload.address_street = streetAddress.trim(); + if (county?.trim()) updatePayload.address_state = county.trim(); + + const { data: updatedProfile, error: supabaseError } = await supabaseAdmin + .from("user_kyc_profiles") + .update(updatePayload) + .eq("wallet_address", walletAddress) + .select("wallet_address"); + + if (supabaseError) { + console.error("Supabase tier3 update error:", supabaseError); + return NextResponse.json( + { + status: "error", + message: "Failed to update KYC profile", + }, + { status: 500 } + ); + } + + if (!updatedProfile || updatedProfile.length === 0) { + return NextResponse.json( + { + status: "error", + message: + "No KYC profile found. Complete phone and ID verification first.", + }, + { status: 404 } + ); + } + + return NextResponse.json({ + status: "success", + message: "Tier 3 address verification completed", + data: { tier: 3 }, + }); + } catch (err) { + console.error("Tier 3 verify error:", err); + const message = + err instanceof Error ? err.message : "Verification failed"; + return NextResponse.json( + { status: "error", message }, + { status: 500 } + ); + } +} diff --git a/app/components/KycModal.tsx b/app/components/KycModal.tsx index db490bd4..17ecfe73 100644 --- a/app/components/KycModal.tsx +++ b/app/components/KycModal.tsx @@ -5,7 +5,6 @@ import { toast } from "sonner"; import { usePrivy, useWallets } from "@privy-io/react-auth"; import { motion, AnimatePresence } from "framer-motion"; import { useState, useEffect } from "react"; - declare global { namespace JSX { interface IntrinsicElements { @@ -14,28 +13,41 @@ declare global { } } - import { CheckIcon, SadFaceIcon, UserDetailsIcon, VerificationPendingIcon, } from "./ImageAssets"; -import { FlexibleDropdown } from "./FlexibleDropdown"; -import { ArrowDown01Icon } from "hugeicons-react"; +import { DropdownItem, FlexibleDropdown } from "./FlexibleDropdown"; +import { + ArrowDown01Icon, + ArrowLeft01Icon, + FileAddIcon, + Folder02Icon, + MapPinpoint01Icon, + PencilEdit01Icon, + PencilEdit02Icon, + Tick01Icon, +} from "hugeicons-react"; import { classNames } from "../utils"; import { fadeInOut } from "./AnimatedComponents"; -import { - fetchKYCStatus, - submitSmileIDData, -} from "../api/aggregator"; +import { fetchKYCStatus, submitSmileIDData } from "../api/aggregator"; import { primaryBtnClasses, secondaryBtnClasses } from "./Styles"; import { trackEvent } from "../hooks/analytics/client"; -import { CheckmarkCircle01Icon, Clock05Icon } from "hugeicons-react"; +import { CheckmarkCircle01Icon, Clock05Icon, StarIcon } from "hugeicons-react"; import { useInjectedWallet } from "../context"; +import { KYC_TIERS, useKYC } from "../context/KYCContext"; +import { formatNumberWithCommas } from "../utils"; +import { DocumentRequirementsModal } from "./kyc/DocumentRequirementsModal"; import idTypesData from "../api/kyc/smile-id/id_types.json"; +const TIER3_DOCUMENT_TYPES = [ + { value: "utility_bill", label: "Utility bill" }, + { value: "bank_statement", label: "Bank statement" }, +] as const; + export const STEPS = { TERMS: "terms", ID_INFO: "id_info", @@ -48,6 +60,10 @@ export const STEPS = { LOADING: "loading", EXPIRED: "expired", REFRESH: "refresh", + // Tier 3 (address verification) flow + TIER3_PROMPT: "tier3_prompt", + TIER3_COUNTRY: "tier3_country", + TIER3_UPLOAD: "tier3_upload", } as const; type Step = @@ -56,7 +72,10 @@ type Step = | typeof STEPS.CAPTURE | typeof STEPS.LOADING | typeof STEPS.REFRESH - | (typeof STEPS.STATUS)[keyof typeof STEPS.STATUS]; + | (typeof STEPS.STATUS)[keyof typeof STEPS.STATUS] + | typeof STEPS.TIER3_PROMPT + | typeof STEPS.TIER3_COUNTRY + | typeof STEPS.TIER3_UPLOAD; // Types for ID types JSON type IdType = { @@ -76,7 +95,10 @@ const getAllCountries = (): Country[] => { .sort((a, b) => a.name.localeCompare(b.name)); }; -const requiresDocumentCapture = (country: Country | null, idType: string): boolean => { +const requiresDocumentCapture = ( + country: Country | null, + idType: string, +): boolean => { if (!country || !idType) return true; const selectedIdType = country.id_types.find((t) => t.type === idType); return selectedIdType?.verification_method === "doc_verification"; @@ -85,9 +107,11 @@ const requiresDocumentCapture = (country: Country | null, idType: string): boole export const KycModal = ({ setIsUserVerified, setIsKycModalOpen, + targetTier, }: { setIsUserVerified: (value: boolean) => void; setIsKycModalOpen: (value: boolean) => void; + targetTier?: 2 | 3; }) => { const { getAccessToken, user } = usePrivy(); const { wallets } = useWallets(); @@ -100,7 +124,9 @@ export const KycModal = ({ ? injectedAddress : embeddedWallet?.address; - const [step, setStep] = useState(STEPS.LOADING); + const [step, setStep] = useState(() => + targetTier === 3 ? STEPS.TIER3_PROMPT : STEPS.LOADING, + ); const [termsAccepted, setTermsAccepted] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [cameraElement, setCameraElement] = useState(null); @@ -110,13 +136,47 @@ export const KycModal = ({ const [selectedCountry, setSelectedCountry] = useState(null); const [selectedIdType, setSelectedIdType] = useState(""); const [idNumber, setIdNumber] = useState(""); + + // Tier 3 (address verification) state + const [tier3CountryCode, setTier3CountryCode] = useState(""); + const [tier3HouseNumber, setTier3HouseNumber] = useState(""); + const [tier3StreetAddress, setTier3StreetAddress] = useState(""); + const [tier3County, setTier3County] = useState(""); + const [tier3PostalCode, setTier3PostalCode] = useState(""); + const [tier3DocumentType, setTier3DocumentType] = useState( + TIER3_DOCUMENT_TYPES[0].value, + ); + const [tier3UploadedFile, setTier3UploadedFile] = useState(null); + const [tier3RequirementsOpen, setTier3RequirementsOpen] = useState(false); + const [tier3Submitting, setTier3Submitting] = useState(false); + const { refreshStatus } = useKYC(); const countries = getAllCountries(); + const tier3CountryOptions = countries.map((c) => ({ + name: c.code, + label: c.name, + imageUrl: `https://flagcdn.com/h24/${c.code.toLowerCase()}.webp`, + })); + const tier3SelectedCountryLabel = + tier3CountryOptions.find((c) => c.name === tier3CountryCode)?.label ?? ""; + const tier3AddressDisplay = + [ + tier3HouseNumber, + tier3StreetAddress, + tier3County, + tier3PostalCode, + tier3SelectedCountryLabel, + ] + .filter(Boolean) + .join(", ") || "โ€”"; // Check if current selection requires document capture or just ID number - const needsDocCapture = requiresDocumentCapture(selectedCountry, selectedIdType); + const needsDocCapture = requiresDocumentCapture( + selectedCountry, + selectedIdType, + ); useEffect(() => { - if (typeof window !== 'undefined' && !smileIdLoaded) { + if (typeof window !== "undefined" && !smileIdLoaded) { import("@smileid/web-components/smart-camera-web") .then(() => { console.log("SmileID web components loaded"); @@ -134,7 +194,6 @@ export const KycModal = ({ setStep(STEPS.ID_INFO); }; - const renderTerms = () => (
    @@ -244,9 +303,9 @@ export const KycModal = ({

    - By clicking “Accept and continue” below, you are agreeing to - the KYC Policy and hereby request an identity verification check for - your wallet address. + By clicking “Accept and continue” below, you are + agreeing to the KYC Policy and hereby request an identity + verification check for your wallet address.

    @@ -280,7 +339,8 @@ export const KycModal = ({ Select your ID document

    - Choose your country and the type of ID you'll use for verification. + Choose your country and the type of ID you'll use for + verification.

    @@ -288,7 +348,7 @@ export const KycModal = ({
    {/* Country Selection */}
    -
    {/* Action Button */} - {tier < 2 && ( + {tier < 4 && ( @@ -244,6 +243,7 @@ export default function TransactionLimitModal({ setIsKycModalOpen(false); onClose(); }} + targetTier={tier === 2 ? 3 : 2} /> )} diff --git a/app/components/kyc/DocumentRequirementsModal.tsx b/app/components/kyc/DocumentRequirementsModal.tsx new file mode 100644 index 00000000..4df46f1f --- /dev/null +++ b/app/components/kyc/DocumentRequirementsModal.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Cancel01Icon, CheckmarkCircle01Icon } from "hugeicons-react"; +import { fadeInOut } from "../AnimatedComponents"; + +const REQUIREMENTS = [ + "Your address must match the address you entered", + "Must be valid and not expired", + "Must clearly show your name, phone and residential address", + "Should not be blurry", +] as const; + +interface DocumentRequirementsModalProps { + isOpen: boolean; + onClose: () => void; + addressDisplay?: string; +} + +export function DocumentRequirementsModal({ + isOpen, + onClose, + addressDisplay, +}: DocumentRequirementsModalProps) { + const list = addressDisplay + ? [ + `Your address must match - ${addressDisplay}`, + ...REQUIREMENTS.slice(1), + ] + : REQUIREMENTS; + + return ( + + {isOpen && ( + + + )} + + ); +} diff --git a/app/components/kyc/index.ts b/app/components/kyc/index.ts new file mode 100644 index 00000000..2641da55 --- /dev/null +++ b/app/components/kyc/index.ts @@ -0,0 +1 @@ +export { DocumentRequirementsModal } from "./DocumentRequirementsModal"; diff --git a/app/context/KYCContext.tsx b/app/context/KYCContext.tsx index 3f0f43e0..020412de 100644 --- a/app/context/KYCContext.tsx +++ b/app/context/KYCContext.tsx @@ -18,10 +18,13 @@ declare global { export interface TransactionLimits { monthly: number; + unlimited?: boolean; } +export type KYCTierLevel = 0 | 1 | 2 | 3 | 4; + export interface KYCTier { - level: 0 | 1 | 2; + level: 1 | 2 | 3 | 4; name: string; limits: TransactionLimits; requirements: string[]; @@ -40,6 +43,18 @@ export const KYC_TIERS: Record = { limits: { monthly: 15000 }, requirements: ["Government ID", "Selfie verification"], }, + 3: { + level: 3, + name: "Tier 3", + limits: { monthly: 50000 }, + requirements: ["Address verification"], + }, + 4: { + level: 4, + name: "Tier 4", + limits: { monthly: 0, unlimited: true }, + requirements: ["Business verification"], + }, }; interface UserTransactionSummary { @@ -49,7 +64,7 @@ interface UserTransactionSummary { } interface KYCContextType { - tier: 0 | 1 | 2; + tier: KYCTierLevel; isPhoneVerified: boolean; phoneNumber: string | null; isFullyVerified: boolean; @@ -74,7 +89,7 @@ export function KYCProvider({ children }: { children: React.ReactNode }) { const fetchGuardsRef = useRef>({}); const guardKey = walletAddress || "no_wallet"; - const [tier, setTier] = useState<0 | 1 | 2>(0); + const [tier, setTier] = useState(0); const [isPhoneVerified, setIsPhoneVerified] = useState(false); const [phoneNumber, setPhoneNumber] = useState(null); const [isFullyVerified, setIsFullyVerified] = useState(false); @@ -98,12 +113,15 @@ export function KYCProvider({ children }: { children: React.ReactNode }) { 0, currentLimits.monthly - transactionSummary.monthlySpent, ); - return { monthly: remaining }; + return { monthly: remaining, unlimited: currentLimits.unlimited }; }, [tier, transactionSummary.monthlySpent]); const canTransact = useCallback( (amount: number): { allowed: boolean; reason?: string } => { const remaining = getRemainingLimits(); + if (remaining.unlimited) { + return { allowed: true }; + } if (amount > remaining.monthly) { return { allowed: false, diff --git a/app/lib/dojah.ts b/app/lib/dojah.ts new file mode 100644 index 00000000..b6991ae4 --- /dev/null +++ b/app/lib/dojah.ts @@ -0,0 +1,71 @@ +/** + * Dojah API client for Tier 3 address verification (utility bill / proof of address). + * Docs: https://docs.dojah.io/docs/document-analysis/utility-bill + */ + +const DOJAH_BASE_URL = + process.env.DOJAH_BASE_URL || "https://api.dojah.io"; +const DOJAH_APP_ID = process.env.DOJAH_APP_ID; +const DOJAH_SECRET_KEY = process.env.DOJAH_SECRET_KEY; + +export interface DojahUtilityBillResponse { + entity?: { + result?: { + status: string; + message?: string; + }; + identity_info?: Record; + address_info?: Record; + provider_name?: string; + bill_issue_date?: string; + amount_paid?: string; + metadata?: { is_recent?: boolean; extraction_date?: string }; + }; +} + +/** + * Submit a utility bill (or similar proof-of-address document) to Dojah for analysis. + * Dojah expects a publicly accessible image URL. + */ +export async function verifyUtilityBill( + imageUrl: string +): Promise { + if (!DOJAH_APP_ID || !DOJAH_SECRET_KEY) { + throw new Error("Dojah is not configured: DOJAH_APP_ID and DOJAH_SECRET_KEY are required"); + } + + const res = await fetch( + `${DOJAH_BASE_URL}/api/v1/document/analysis/utility_bill`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + AppId: DOJAH_APP_ID, + Authorization: DOJAH_SECRET_KEY, + }, + body: JSON.stringify({ + input_type: "url", + input_value: imageUrl, + }), + } + ); + + const data = (await res.json()) as DojahUtilityBillResponse & { message?: string }; + + if (!res.ok) { + const message = data?.message || data?.entity?.result?.message || res.statusText; + throw new Error(message || `Dojah request failed: ${res.status}`); + } + + return data; +} + +/** + * Check if Dojah verification result indicates success. + */ +export function isDojahVerificationSuccess( + data: DojahUtilityBillResponse +): boolean { + const status = data?.entity?.result?.status; + return status === "success"; +} diff --git a/app/pages/TransactionForm.tsx b/app/pages/TransactionForm.tsx index cfc892aa..2fcd8fe9 100644 --- a/app/pages/TransactionForm.tsx +++ b/app/pages/TransactionForm.tsx @@ -844,6 +844,7 @@ export const TransactionForm = ({ )} diff --git a/middleware.ts b/middleware.ts index bf0e6b66..09e57fc1 100644 --- a/middleware.ts +++ b/middleware.ts @@ -266,6 +266,7 @@ export const config = { "/api/blockfest/cashback", "/api/kyc/smile-id", "/api/kyc/status", + "/api/kyc/tier3-verify", "/api/phone/send-otp", "/api/phone/verify-otp", // (optional) add other instrumented API routes: From 561f7be1ca62617c7817e1fbb444f40f450426e1 Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Tue, 10 Feb 2026 15:18:19 +0100 Subject: [PATCH 20/22] feat(kyc): enhance Tier 3 file upload validation and error handling --- app/api/kyc/tier3-verify/route.ts | 61 ++++++++++++++++-- app/components/KycModal.tsx | 39 +++++++---- app/components/ProfileDrawer.tsx | 4 +- app/components/TransactionLimitModal.tsx | 5 +- .../kyc/DocumentRequirementsModal.tsx | 64 ++++++++++--------- app/context/KYCContext.tsx | 20 ++++++ 6 files changed, 139 insertions(+), 54 deletions(-) diff --git a/app/api/kyc/tier3-verify/route.ts b/app/api/kyc/tier3-verify/route.ts index 296148d8..282f687a 100644 --- a/app/api/kyc/tier3-verify/route.ts +++ b/app/api/kyc/tier3-verify/route.ts @@ -8,6 +8,13 @@ import { rateLimit } from "@/app/lib/rate-limit"; const KYC_BUCKET = process.env.KYC_DOCUMENTS_BUCKET || "kyc-documents"; const SIGNED_URL_EXPIRY_SEC = 3600; +const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB +const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"] as const; +const MIME_TO_EXT: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", +}; export async function POST(request: NextRequest) { const rateLimitResult = await rateLimit(request); @@ -49,10 +56,29 @@ export async function POST(request: NextRequest) { ); } - const buffer = Buffer.from(await file.arrayBuffer()); - const ext = file.name.split(".").pop() || "jpg"; + if (file.size > MAX_FILE_BYTES) { + return NextResponse.json( + { status: "error", message: "File too large; maximum 10 MB" }, + { status: 413 } + ); + } + const mime = (file.type || "").toLowerCase(); + if (!ALLOWED_MIME_TYPES.includes(mime as (typeof ALLOWED_MIME_TYPES)[number])) { + return NextResponse.json( + { + status: "error", + message: "Invalid file type; allowed: image/jpeg, image/png, image/webp", + }, + { status: 400 } + ); + } + + const nameExt = file.name?.split(".").pop(); + const ext = (nameExt && nameExt.length <= 4 ? nameExt : MIME_TO_EXT[mime]) || "jpg"; const path = `tier3/${walletAddress}/${Date.now()}.${ext}`; + const buffer = Buffer.from(await file.arrayBuffer()); + const { error: uploadError } = await supabaseAdmin.storage .from(KYC_BUCKET) .upload(path, buffer, { @@ -102,14 +128,37 @@ export async function POST(request: NextRequest) { ); } - const { data: existingProfile } = await supabaseAdmin + const { data: currentProfile, error: fetchError } = await supabaseAdmin .from("user_kyc_profiles") - .select("platform") + .select("tier, platform") .eq("wallet_address", walletAddress) .single(); - const existingPlatform = Array.isArray(existingProfile?.platform) - ? existingProfile.platform + if (fetchError || !currentProfile) { + return NextResponse.json( + { + status: "error", + message: + "No KYC profile found. Complete phone and ID verification first.", + }, + { status: 404 } + ); + } + + const currentTier = Number(currentProfile.tier) ?? 0; + if (currentTier < 2) { + return NextResponse.json( + { + status: "error", + message: + "Complete Tier 1 (phone) and Tier 2 (ID) verification before upgrading to Tier 3.", + }, + { status: 400 } + ); + } + + const existingPlatform = Array.isArray(currentProfile?.platform) + ? currentProfile.platform : []; const otherVerifications = existingPlatform.filter( (p: { type: string }) => p.type !== "address" diff --git a/app/components/KycModal.tsx b/app/components/KycModal.tsx index 17ecfe73..2fde0002 100644 --- a/app/components/KycModal.tsx +++ b/app/components/KycModal.tsx @@ -149,6 +149,7 @@ export const KycModal = ({ const [tier3UploadedFile, setTier3UploadedFile] = useState(null); const [tier3RequirementsOpen, setTier3RequirementsOpen] = useState(false); const [tier3Submitting, setTier3Submitting] = useState(false); + const [tier3ErrorMessage, setTier3ErrorMessage] = useState(null); const { refreshStatus } = useKYC(); const countries = getAllCountries(); const tier3CountryOptions = countries.map((c) => ({ @@ -831,29 +832,40 @@ export const KycModal = ({ ); + const ALLOWED_TIER3_EXTENSIONS = ["JPG", "PNG", "PDF", "DOC", "JPEG", "DOCX"]; + const TIER3_MAX_BYTES = 5 * 1024 * 1024; + const renderTier3Upload = () => { const handleTier3FileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const ext = file.name.split(".").pop()?.toUpperCase(); - if (ext && !["JPG", "PNG", "PDF", "DOC", "JPEG", "DOCX"].includes(ext)) + if (!ext || !ALLOWED_TIER3_EXTENSIONS.includes(ext)) { + setTier3ErrorMessage("Invalid file type; allowed: JPG, PNG, PDF, DOC, JPEG, DOCX"); + return; + } + if (file.size > TIER3_MAX_BYTES) { + setTier3ErrorMessage("File too large; maximum 5 MB"); return; - if (file.size > 5 * 1024 * 1024) return; + } + setTier3ErrorMessage(null); setTier3UploadedFile(file); }; const handleTier3Drop = (e: React.DragEvent) => { e.preventDefault(); const file = e.dataTransfer.files?.[0]; - if (file) { - const ext = file.name.split(".").pop()?.toUpperCase(); - if ( - ext && - ["JPG", "PNG", "PDF", "DOC", "JPEG", "DOCX"].includes(ext) && - file.size <= 5 * 1024 * 1024 - ) { - setTier3UploadedFile(file); - } + if (!file) return; + const ext = file.name.split(".").pop()?.toUpperCase(); + if (!ext || !ALLOWED_TIER3_EXTENSIONS.includes(ext)) { + setTier3ErrorMessage("Invalid file type; allowed: JPG, PNG, PDF, DOC, JPEG, DOCX"); + return; + } + if (file.size > TIER3_MAX_BYTES) { + setTier3ErrorMessage("File too large; maximum 5 MB"); + return; } + setTier3ErrorMessage(null); + setTier3UploadedFile(file); }; const docLabel = TIER3_DOCUMENT_TYPES.find( @@ -960,7 +972,7 @@ export const KycModal = ({ {tier3UploadedFile.name} change

    - Size: {(tier3UploadedFile.size / 1024 / 1024).toFixed(2)} MB Format: {tier3UploadedFile.type.split("/")[1].toUpperCase()} + Size: {(tier3UploadedFile.size / 1024 / 1024).toFixed(2)} MB Format: {String(tier3UploadedFile.type?.split("/")[1] ?? tier3UploadedFile.name?.split(".").pop() ?? "UNKNOWN").toUpperCase()}

    ) : ( @@ -982,6 +994,9 @@ export const KycModal = ({

    )} + {tier3ErrorMessage && ( +

    {tier3ErrorMessage}

    + )}
    )} diff --git a/app/components/ProfileDrawer.tsx b/app/components/ProfileDrawer.tsx index bb88110b..317bb90b 100644 --- a/app/components/ProfileDrawer.tsx +++ b/app/components/ProfileDrawer.tsx @@ -167,7 +167,7 @@ export default function ProfileDrawer({ isOpen, onClose }: ProfileDrawerProps) {
    @@ -379,7 +379,7 @@ export default function ProfileDrawer({ isOpen, onClose }: ProfileDrawerProps) { {/* Progress Bar */}
    { + const handlePhoneVerified = async () => { setIsPhoneModalOpen(false); - refreshStatus(); - onClose(); + await refreshStatus(); }; const renderLoadingStatus = () => ( diff --git a/app/components/kyc/DocumentRequirementsModal.tsx b/app/components/kyc/DocumentRequirementsModal.tsx index 4df46f1f..e9bc0810 100644 --- a/app/components/kyc/DocumentRequirementsModal.tsx +++ b/app/components/kyc/DocumentRequirementsModal.tsx @@ -36,38 +36,40 @@ export function DocumentRequirementsModal({ )} diff --git a/app/context/KYCContext.tsx b/app/context/KYCContext.tsx index 020412de..229ac0bb 100644 --- a/app/context/KYCContext.tsx +++ b/app/context/KYCContext.tsx @@ -143,6 +143,16 @@ export function KYCProvider({ children }: { children: React.ReactNode }) { const response = await fetch( `/api/kyc/transaction-summary?walletAddress=${encodeURIComponent(walletAddress)}`, ); + if (!response.ok) { + let body: unknown; + try { + body = await response.json(); + } catch { + body = await response.text(); + } + console.error("Transaction summary fetch failed:", response.status, response.statusText, body); + return; + } const data = await response.json(); if (data.success) { setTransactionSummary({ @@ -178,6 +188,16 @@ export function KYCProvider({ children }: { children: React.ReactNode }) { Authorization: `Bearer ${accessToken}`, }, }); + if (!response.ok) { + let body: unknown; + try { + body = await response.json(); + } catch { + body = await response.text(); + } + console.error("KYC status fetch failed:", response.status, response.statusText, body); + return; + } const data = await response.json(); if (data.success) { setTier(data.tier); From b76fa1b2ea01ab5201d4a655c1b1c8bcbe6cf57b Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Wed, 11 Feb 2026 08:58:06 +0100 Subject: [PATCH 21/22] feat: Enhance KYC and OTP verification processes --- app/api/kyc/smile-id/route.ts | 143 +++++++----- app/api/kyc/status/route.ts | 49 ++-- app/api/kyc/tier3-verify/route.ts | 63 +++-- app/api/kyc/transaction-summary/route.ts | 76 +++--- app/api/phone/send-otp/route.ts | 22 +- app/api/phone/verify-otp/route.ts | 32 +-- app/components/KycModal.tsx | 268 ++++++++++++++++------ app/components/PhoneVerificationModal.tsx | 5 +- app/components/ProfileDrawer.tsx | 31 ++- app/components/SettingsDropdown.tsx | 8 +- app/components/TransactionLimitModal.tsx | 15 +- app/context/KYCContext.tsx | 82 +++---- app/types.ts | 3 +- middleware.ts | 1 + 14 files changed, 473 insertions(+), 325 deletions(-) diff --git a/app/api/kyc/smile-id/route.ts b/app/api/kyc/smile-id/route.ts index db979cde..a8d87b26 100644 --- a/app/api/kyc/smile-id/route.ts +++ b/app/api/kyc/smile-id/route.ts @@ -1,25 +1,28 @@ import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from '@/app/lib/supabase'; -import { submitSmileIDJob, type SmileIDIdInfo } from '@/app/lib/smileID'; -import { rateLimit } from '@/app/lib/rate-limit'; +import { supabaseAdmin } from "@/app/lib/supabase"; +import { submitSmileIDJob, type SmileIDIdInfo } from "@/app/lib/smileID"; +import { rateLimit } from "@/app/lib/rate-limit"; export async function POST(request: NextRequest) { // Rate limit check const rateLimitResult = await rateLimit(request); if (!rateLimitResult.success) { return NextResponse.json( - { status: "error", message: "Too many requests. Please try again later." }, - { status: 429 } + { + status: "error", + message: "Too many requests. Please try again later.", + }, + { status: 429 }, ); } // Get the wallet address from the header set by the middleware - const walletAddress = request.headers.get("x-wallet-address")?.toLowerCase(); + const walletAddress = request.headers.get("x-wallet-address"); if (!walletAddress) { return NextResponse.json( { status: "error", message: "Unauthorized" }, - { status: 401 } + { status: 401 }, ); } @@ -31,15 +34,18 @@ export async function POST(request: NextRequest) { if (!images || !Array.isArray(images) || images.length === 0) { return NextResponse.json( { status: "error", message: "Invalid images data" }, - { status: 400 } + { status: 400 }, ); } // Validate id_info for Job Type 1 (Biometric KYC) if (!id_info?.country || !id_info?.id_type) { return NextResponse.json( - { status: "error", message: "Missing id_info: country and id_type are required" }, - { status: 400 } + { + status: "error", + message: "Missing id_info: country and id_type are required", + }, + { status: 400 }, ); } @@ -50,18 +56,27 @@ export async function POST(request: NextRequest) { [key: string]: any; }; - let smileIdResult: SmileIdResultType = { job_complete: false }, job_id: string, user_id: string; + let smileIdResult: SmileIdResultType = { job_complete: false }, + job_id: string, + user_id: string; try { - const result = await submitSmileIDJob({ images, partner_params, walletAddress, id_info: id_info as SmileIDIdInfo }); + const result = await submitSmileIDJob({ + images, + partner_params, + walletAddress, + id_info: id_info as SmileIDIdInfo, + }); smileIdResult = { job_complete: false, ...result.smileIdResult }; job_id = result.job_id; user_id = result.user_id; } catch (err) { - console.error('SmileID job submission error:', err); - return NextResponse.json({ - status: 'error', - message: err instanceof Error ? err.message : 'SmileID job failed', - }, { status: 500 }); + return NextResponse.json( + { + status: "error", + message: err instanceof Error ? err.message : "SmileID job failed", + }, + { status: 500 }, + ); } // Enhanced KYC (Job Type 5) returns Actions.Verify_ID_Number @@ -74,45 +89,56 @@ export async function POST(request: NextRequest) { if (isEnhancedKyc) { // Enhanced KYC: Check if ID verification passed - verificationSuccess = actions.Verify_ID_Number === 'Verified'; + verificationSuccess = actions.Verify_ID_Number === "Verified"; } else if (isBiometricKyc) { // Biometric KYC: Check job_complete and job_success - verificationSuccess = smileIdResult.job_complete && smileIdResult.job_success; + verificationSuccess = + smileIdResult.job_complete && smileIdResult.job_success; } if (!verificationSuccess) { - const errorMessage = smileIdResult?.ResultText || 'SmileID verification failed'; - console.error('SmileID verification failed:'); - return NextResponse.json({ - status: 'error', - message: errorMessage, - data: smileIdResult, - }, { status: 400 }); + const errorMessage = + smileIdResult?.ResultText || "SmileID verification failed"; + return NextResponse.json( + { + status: "error", + message: errorMessage, + }, + { status: 400 }, + ); } // Extract ID info from Smile ID response if available const smileIdInfo = smileIdResult?.id_info || {}; const { data: existingProfile } = await supabaseAdmin - .from('user_kyc_profiles') - .select('platform') - .eq('wallet_address', walletAddress.toLowerCase()) + .from("user_kyc_profiles") + .select("platform, tier") + .eq("wallet_address", walletAddress) .single(); - const existingPlatform = Array.isArray(existingProfile?.platform) ? existingProfile.platform : []; - const otherVerifications = existingPlatform.filter((p: { type: string }) => p.type !== 'id'); + const existingPlatform = Array.isArray(existingProfile?.platform) + ? existingProfile.platform + : []; + const otherVerifications = existingPlatform.filter( + (p: { type: string }) => p.type !== "id", + ); const updatedPlatform = [ ...otherVerifications, { - type: 'id', - identifier: 'smile_id', + type: "id", + identifier: "smile_id", reference: job_id, verified: true, }, ]; + // Prevent tier downgrade โ€” only upgrade to 2 if current tier is lower + const currentTier = Number(existingProfile?.tier) || 0; + const newTier = Math.max(currentTier, 2); + const { data: updatedProfile, error: supabaseError } = await supabaseAdmin - .from('user_kyc_profiles') + .from("user_kyc_profiles") .update({ // Email from user's Privy profile (if provided) ...(email && { email_address: email }), @@ -121,56 +147,57 @@ export async function POST(request: NextRequest) { id_number: smileIdInfo.id_number || id_info.id_number, id_country: id_info.country, // Personal info from Smile ID response - full_name: smileIdInfo.full_name || (smileIdInfo.first_name && smileIdInfo.last_name - ? `${smileIdInfo.first_name} ${smileIdInfo.last_name}` - : null), + full_name: + smileIdInfo.full_name || + (smileIdInfo.first_name && smileIdInfo.last_name + ? `${smileIdInfo.first_name} ${smileIdInfo.last_name}` + : null), date_of_birth: smileIdInfo.dob || id_info.dob || null, platform: updatedPlatform, verified: true, verified_at: new Date().toISOString(), - tier: 2, + tier: newTier, }) - .eq('wallet_address', walletAddress.toLowerCase()) - .select('wallet_address'); + .eq("wallet_address", walletAddress) + .select("wallet_address"); if (supabaseError) { - console.error('Supabase update error:', supabaseError); - return NextResponse.json({ - status: 'error', - message: 'Failed to save KYC data to Supabase', - data: supabaseError, - }, { status: 500 }); + return NextResponse.json( + { + status: "error", + message: "Failed to save KYC data", + }, + { status: 500 }, + ); } // Verify that a row was actually updated if (!updatedProfile || updatedProfile.length === 0) { - console.error('No KYC profile found to update for wallet:', walletAddress); - return NextResponse.json({ - status: 'error', - message: 'No KYC profile exists. Please complete phone verification first.', - }, { status: 404 }); + return NextResponse.json( + { + status: "error", + message: + "No KYC profile exists. Please complete phone verification first.", + }, + { status: 404 }, + ); } - return NextResponse.json({ status: "success", message: "KYC verification submitted and saved successfully", data: { jobId: job_id, userId: user_id, - smileIdResponse: smileIdResult, }, }); } catch (error) { - console.error("Error in SmileID submission:", error); - return NextResponse.json( { status: "error", message: error instanceof Error ? error.message : "Unknown error", - error: error instanceof Error ? error.stack : undefined, }, - { status: 500 } + { status: 500 }, ); } -} \ No newline at end of file +} diff --git a/app/api/kyc/status/route.ts b/app/api/kyc/status/route.ts index c199fa7a..2823e9a7 100644 --- a/app/api/kyc/status/route.ts +++ b/app/api/kyc/status/route.ts @@ -1,56 +1,61 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { supabaseAdmin } from '@/app/lib/supabase'; -import { trackApiRequest, trackApiResponse, trackApiError } from '@/app/lib/server-analytics'; +import { NextRequest, NextResponse } from "next/server"; +import { supabaseAdmin } from "@/app/lib/supabase"; +import { + trackApiRequest, + trackApiResponse, + trackApiError, +} from "@/app/lib/server-analytics"; export async function GET(request: NextRequest) { const startTime = Date.now(); try { - trackApiRequest(request, '/api/kyc/status', 'GET'); + trackApiRequest(request, "/api/kyc/status", "GET"); // Get the wallet address from the header set by the middleware - const walletAddress = request.headers.get("x-wallet-address")?.toLowerCase(); + const walletAddress = request.headers.get("x-wallet-address"); if (!walletAddress) { - trackApiError(request, '/api/kyc/status', 'GET', new Error('Unauthorized'), 401); + trackApiError( + request, + "/api/kyc/status", + "GET", + new Error("Unauthorized"), + 401, + ); return NextResponse.json( - { success: false, error: 'Unauthorized' }, - { status: 401 } + { success: false, error: "Unauthorized" }, + { status: 401 }, ); } // Check KYC profile for phone and SmileID verification status const { data: kycProfile } = await supabaseAdmin - .from('user_kyc_profiles') - .select('verified, phone_number, tier') - .eq('wallet_address', walletAddress.toLowerCase()) + .from("user_kyc_profiles") + .select("verified, phone_number, tier") + .eq("wallet_address", walletAddress) .single(); - const tier: 0 | 1 | 2 | 3 | 4 = (kycProfile?.tier as 0 | 1 | 2 | 3 | 4) || 0; + const tier: 0 | 1 | 2 | 3 | 4 = + (kycProfile?.tier as 0 | 1 | 2 | 3 | 4) || 0; const phoneNumber = kycProfile?.phone_number || null; const phoneVerified = kycProfile?.verified && phoneNumber ? true : false; - // Full KYC (SmileID) is verified if tier is at least 2; Tier 3/4 add address verification - const fullKYCVerified = tier >= 2; - const responseTime = Date.now() - startTime; - trackApiResponse( '/api/kyc/status', 'GET', 200, responseTime); + trackApiResponse("/api/kyc/status", "GET", 200, responseTime); return NextResponse.json({ success: true, tier, isPhoneVerified: phoneVerified, phoneNumber, - isFullyVerified: fullKYCVerified, }); - } catch (error) { - console.error('KYC status check error:', error); trackApiError(request, '/api/kyc/status', 'GET', error as Error, 500); return NextResponse.json( - { success: false, error: 'Internal server error' }, - { status: 500 } + { success: false, error: "Internal server error" }, + { status: 500 }, ); } -} \ No newline at end of file +} diff --git a/app/api/kyc/tier3-verify/route.ts b/app/api/kyc/tier3-verify/route.ts index 282f687a..1b4fcba1 100644 --- a/app/api/kyc/tier3-verify/route.ts +++ b/app/api/kyc/tier3-verify/route.ts @@ -20,15 +20,15 @@ export async function POST(request: NextRequest) { const rateLimitResult = await rateLimit(request); if (!rateLimitResult.success) { return NextResponse.json( - { status: "error", message: "Too many requests. Please try again later." }, + { success: false, error: "Too many requests. Please try again later." }, { status: 429 } ); } - const walletAddress = request.headers.get("x-wallet-address")?.toLowerCase(); + const walletAddress = request.headers.get("x-wallet-address"); if (!walletAddress) { return NextResponse.json( - { status: "error", message: "Unauthorized" }, + { success: false, error: "Unauthorized" }, { status: 401 } ); } @@ -45,20 +45,20 @@ export async function POST(request: NextRequest) { if (!file || file.size === 0) { return NextResponse.json( - { status: "error", message: "Document file is required" }, + { success: false, error: "Document file is required" }, { status: 400 } ); } if (!countryCode?.trim()) { return NextResponse.json( - { status: "error", message: "Country is required" }, + { success: false, error: "Country is required" }, { status: 400 } ); } if (file.size > MAX_FILE_BYTES) { return NextResponse.json( - { status: "error", message: "File too large; maximum 10 MB" }, + { success: false, error: "File too large; maximum 5 MB" }, { status: 413 } ); } @@ -66,8 +66,8 @@ export async function POST(request: NextRequest) { if (!ALLOWED_MIME_TYPES.includes(mime as (typeof ALLOWED_MIME_TYPES)[number])) { return NextResponse.json( { - status: "error", - message: "Invalid file type; allowed: image/jpeg, image/png, image/webp", + success: false, + error: "Invalid file type; allowed: image/jpeg, image/png, image/webp", }, { status: 400 } ); @@ -87,11 +87,10 @@ export async function POST(request: NextRequest) { }); if (uploadError) { - console.error("KYC document upload error:", uploadError); return NextResponse.json( { - status: "error", - message: + success: false, + error: uploadError.message || "Failed to upload document. Ensure the KYC storage bucket exists.", }, @@ -99,19 +98,17 @@ export async function POST(request: NextRequest) { ); } - const { - data: { signedUrl }, - error: signError, - } = await supabaseAdmin.storage - .from(KYC_BUCKET) - .createSignedUrl(path, SIGNED_URL_EXPIRY_SEC); + const { data: signedUrlData, error: signError } = + await supabaseAdmin.storage + .from(KYC_BUCKET) + .createSignedUrl(path, SIGNED_URL_EXPIRY_SEC); + const signedUrl = signedUrlData?.signedUrl; if (signError || !signedUrl) { - console.error("Signed URL creation error:", signError); return NextResponse.json( { - status: "error", - message: "Failed to generate document URL", + success: false, + error: "Failed to generate document URL", }, { status: 500 } ); @@ -123,7 +120,7 @@ export async function POST(request: NextRequest) { dojahResult?.entity?.result?.message || "Document could not be verified as a valid proof of address."; return NextResponse.json( - { status: "error", message: msg }, + { success: false, error: msg }, { status: 400 } ); } @@ -137,8 +134,8 @@ export async function POST(request: NextRequest) { if (fetchError || !currentProfile) { return NextResponse.json( { - status: "error", - message: + success: false, + error: "No KYC profile found. Complete phone and ID verification first.", }, { status: 404 } @@ -149,8 +146,8 @@ export async function POST(request: NextRequest) { if (currentTier < 2) { return NextResponse.json( { - status: "error", - message: + success: false, + error: "Complete Tier 1 (phone) and Tier 2 (ID) verification before upgrading to Tier 3.", }, { status: 400 } @@ -173,7 +170,7 @@ export async function POST(request: NextRequest) { ]; const updatePayload: Record = { - tier: 3, + tier: Math.max(currentTier, 3), verified: true, verified_at: new Date().toISOString(), platform: updatedPlatform, @@ -196,11 +193,10 @@ export async function POST(request: NextRequest) { .select("wallet_address"); if (supabaseError) { - console.error("Supabase tier3 update error:", supabaseError); return NextResponse.json( { - status: "error", - message: "Failed to update KYC profile", + success: false, + error: "Failed to update KYC profile", }, { status: 500 } ); @@ -209,8 +205,8 @@ export async function POST(request: NextRequest) { if (!updatedProfile || updatedProfile.length === 0) { return NextResponse.json( { - status: "error", - message: + success: false, + error: "No KYC profile found. Complete phone and ID verification first.", }, { status: 404 } @@ -218,16 +214,15 @@ export async function POST(request: NextRequest) { } return NextResponse.json({ - status: "success", + success: true, message: "Tier 3 address verification completed", data: { tier: 3 }, }); } catch (err) { - console.error("Tier 3 verify error:", err); const message = err instanceof Error ? err.message : "Verification failed"; return NextResponse.json( - { status: "error", message }, + { success: false, error: message }, { status: 500 } ); } diff --git a/app/api/kyc/transaction-summary/route.ts b/app/api/kyc/transaction-summary/route.ts index caac342a..2961450d 100644 --- a/app/api/kyc/transaction-summary/route.ts +++ b/app/api/kyc/transaction-summary/route.ts @@ -1,21 +1,31 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { supabaseAdmin } from '@/app/lib/supabase'; -import { trackApiRequest, trackApiResponse, trackApiError } from '@/app/lib/server-analytics'; +import { NextRequest, NextResponse } from "next/server"; +import { supabaseAdmin } from "@/app/lib/supabase"; +import { + trackApiRequest, + trackApiResponse, + trackApiError, +} from "@/app/lib/server-analytics"; export async function GET(request: NextRequest) { const startTime = Date.now(); - + try { - trackApiRequest(request, '/api/kyc/transaction-summary', 'GET'); + trackApiRequest(request, "/api/kyc/transaction-summary", "GET"); - const { searchParams } = new URL(request.url); - const walletAddress = searchParams.get('walletAddress'); + // Get the wallet address from the header set by the middleware + const walletAddress = request.headers.get("x-wallet-address"); if (!walletAddress) { - trackApiError(request, '/api/kyc/transaction-summary', 'GET', new Error('Missing wallet address'), 400); + trackApiError( + request, + "/api/kyc/transaction-summary", + "GET", + new Error("Unauthorized"), + 401, + ); return NextResponse.json( - { success: false, error: 'Wallet address is required' }, - { status: 400 } + { success: false, error: "Unauthorized" }, + { status: 401 }, ); } @@ -23,20 +33,20 @@ export async function GET(request: NextRequest) { const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); - // Fetch transactions from the last 30 days + // Fetch transactions for the current month const { data: transactions, error } = await supabaseAdmin - .from('transactions') - .select('amount_sent, created_at') - .eq('wallet_address', walletAddress.toLowerCase()) - .in('status', ['fulfilling', 'completed']) - .gte('created_at', monthStart.toISOString()); + .from("transactions") + .select("amount_sent, created_at") + .eq("wallet_address", walletAddress) + .eq("transaction_type", "swap") + .in("status", ["fulfilling", "completed"]) + .gte("created_at", monthStart.toISOString()); if (error) { - console.error('Error fetching transaction summary:', error); - trackApiError(request, '/api/kyc/transaction-summary', 'GET', error, 500); + trackApiError(request, "/api/kyc/transaction-summary", "GET", error, 500); return NextResponse.json( - { success: false, error: 'Failed to fetch transaction summary' }, - { status: 500 } + { success: false, error: "Failed to fetch transaction summary" }, + { status: 500 }, ); } @@ -44,12 +54,12 @@ export async function GET(request: NextRequest) { let monthlySpent = 0; let lastTransactionDate: string | null = null; - transactions?.forEach(tx => { + transactions?.forEach((tx) => { const txDate = new Date(tx.created_at); const amount = parseFloat(tx.amount_sent) || 0; - + monthlySpent += amount; - + if (txDate >= today) { dailySpent += amount; } @@ -60,7 +70,7 @@ export async function GET(request: NextRequest) { }); const responseTime = Date.now() - startTime; - trackApiResponse('/api/kyc/transaction-summary', 'GET', 200, responseTime); + trackApiResponse("/api/kyc/transaction-summary", "GET", 200, responseTime); return NextResponse.json({ success: true, @@ -68,14 +78,18 @@ export async function GET(request: NextRequest) { monthlySpent, lastTransactionDate, }); - } catch (error) { - console.error('Transaction summary error:', error); - trackApiError(request, '/api/kyc/transaction-summary', 'GET', error as Error, 500); - + trackApiError( + request, + "/api/kyc/transaction-summary", + "GET", + error as Error, + 500, + ); + return NextResponse.json( - { success: false, error: 'Internal server error' }, - { status: 500 } + { success: false, error: "Internal server error" }, + { status: 500 }, ); } -} \ No newline at end of file +} diff --git a/app/api/phone/send-otp/route.ts b/app/api/phone/send-otp/route.ts index 5babedec..8385e735 100644 --- a/app/api/phone/send-otp/route.ts +++ b/app/api/phone/send-otp/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { createHash } from "crypto"; import { supabaseAdmin } from "@/app/lib/supabase"; import { validatePhoneNumber, @@ -13,6 +14,10 @@ import { } from "../../../lib/server-analytics"; import { rateLimit } from "@/app/lib/rate-limit"; +function hashOTP(otp: string): string { + return createHash("sha256").update(otp).digest("hex"); +} + export async function POST(request: NextRequest) { const startTime = Date.now(); @@ -32,9 +37,7 @@ export async function POST(request: NextRequest) { const { phoneNumber, name } = body; // Use authenticated wallet address and user ID from middleware - const walletAddress = request.headers - .get("x-wallet-address") - ?.toLowerCase(); + const walletAddress = request.headers.get("x-wallet-address"); const userId = request.headers.get("x-user-id"); if (!walletAddress) { @@ -90,25 +93,26 @@ export async function POST(request: NextRequest) { .select( "tier, verified, verified_at, id_country, id_type, platform, full_name", ) - .eq("wallet_address", walletAddress.toLowerCase()) + .eq("wallet_address", walletAddress) .single(); const isNigerian = validation.isNigerian; const expiresAt = new Date(Date.now() + (isNigerian ? 5 : 10) * 60 * 1000); // 5 min KudiSMS, 10 min Twilio Verify - // Nigerian: we generate OTP and store it. Non-Nigerian: Twilio Verify sends its own code, we don't store one. + // Nigerian: we generate OTP, hash it, and store the hash. Non-Nigerian: Twilio Verify sends its own code. const otp = isNigerian ? generateOTP() : null; + const otpHash = otp ? hashOTP(otp) : null; - // Store verification record (otp_code only for Nigerian/KudiSMS path) + // Store verification record (otp_code hash only for Nigerian/KudiSMS path) const { error: dbError } = await supabaseAdmin .from("user_kyc_profiles") .upsert( { - wallet_address: walletAddress.toLowerCase(), + wallet_address: walletAddress, user_id: userId, full_name: name || existingProfile?.full_name || null, phone_number: validation.e164Format, - otp_code: otp, + otp_code: otpHash, expires_at: expiresAt.toISOString(), verified: existingProfile?.verified || false, verified_at: existingProfile?.verified_at || null, @@ -125,7 +129,6 @@ export async function POST(request: NextRequest) { ); if (dbError) { - console.error("Database error:", dbError); trackApiError(request, "/api/phone/send-otp", "POST", dbError, 500); return NextResponse.json( { success: false, error: "Failed to store verification data" }, @@ -166,7 +169,6 @@ export async function POST(request: NextRequest) { phoneNumber: validation.internationalFormat, }); } catch (error) { - console.error("Send OTP error:", error); trackApiError(request, "/api/phone/send-otp", "POST", error as Error, 500); return NextResponse.json( diff --git a/app/api/phone/verify-otp/route.ts b/app/api/phone/verify-otp/route.ts index 50991d56..9754cad6 100644 --- a/app/api/phone/verify-otp/route.ts +++ b/app/api/phone/verify-otp/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { createHash } from "crypto"; import { supabaseAdmin } from "@/app/lib/supabase"; import { trackApiRequest, @@ -12,6 +13,10 @@ import { const MAX_ATTEMPTS = 3; +function hashOTP(otp: string): string { + return createHash("sha256").update(otp).digest("hex"); +} + export async function POST(request: NextRequest) { const startTime = Date.now(); @@ -22,9 +27,7 @@ export async function POST(request: NextRequest) { const { phoneNumber, otpCode } = body; // Use authenticated wallet address from middleware - const walletAddress = request.headers - .get("x-wallet-address") - ?.toLowerCase(); + const walletAddress = request.headers.get("x-wallet-address"); if (!walletAddress) { trackApiError( @@ -73,13 +76,12 @@ export async function POST(request: NextRequest) { // Get verification record using normalized E.164 format const { data: verification, error: fetchError } = await supabaseAdmin .from("user_kyc_profiles") - .select("*") - .eq("wallet_address", walletAddress.toLowerCase()) + .select("verified, provider, tier, expires_at, attempts, otp_code") + .eq("wallet_address", walletAddress) .eq("phone_number", validation.e164Format) .single(); if (fetchError) { - console.error("Database error fetching verification record:", fetchError); trackApiError(request, "/api/phone/verify-otp", "POST", fetchError, 500); return NextResponse.json( { success: false, error: "Failed to fetch verification record" }, @@ -140,6 +142,8 @@ export async function POST(request: NextRequest) { const updateData: Record = { verified: true, verified_at: new Date().toISOString(), + otp_code: null, + attempts: 0, }; if (verification.tier === 0) { updateData.tier = 1; @@ -147,9 +151,8 @@ export async function POST(request: NextRequest) { const { error: updateError } = await supabaseAdmin .from("user_kyc_profiles") .update(updateData) - .eq("wallet_address", walletAddress.toLowerCase()); + .eq("wallet_address", walletAddress); if (updateError) { - console.error("Update error:", updateError); trackApiError( request, "/api/phone/verify-otp", @@ -191,18 +194,17 @@ export async function POST(request: NextRequest) { ); } - if (verification.otp_code !== otpCode) { + if (verification.otp_code !== hashOTP(otpCode)) { // Atomic increment with boundary check to prevent race conditions const { data: updated, error: attemptsError } = await supabaseAdmin .from("user_kyc_profiles") .update({ attempts: verification.attempts + 1 }) - .eq("wallet_address", walletAddress.toLowerCase()) + .eq("wallet_address", walletAddress) .lt("attempts", MAX_ATTEMPTS) .select("attempts") .single(); if (attemptsError) { - console.error("Failed to increment OTP attempts:", attemptsError); trackApiError( request, "/api/phone/verify-otp", @@ -239,9 +241,11 @@ export async function POST(request: NextRequest) { } // Mark as verified - preserve existing tier if higher than 1 - const updateData: any = { + const updateData: Record = { verified: true, verified_at: new Date().toISOString(), + otp_code: null, // Clear OTP hash after successful verification + attempts: 0, }; // Only set tier to 1 if current tier is 0 (unverified) @@ -252,10 +256,9 @@ export async function POST(request: NextRequest) { const { error: updateError } = await supabaseAdmin .from("user_kyc_profiles") .update(updateData) - .eq("wallet_address", walletAddress.toLowerCase()); + .eq("wallet_address", walletAddress); if (updateError) { - console.error("Update error:", updateError); trackApiError(request, "/api/phone/verify-otp", "POST", updateError, 500); return NextResponse.json( { success: false, error: "Failed to update verification status" }, @@ -273,7 +276,6 @@ export async function POST(request: NextRequest) { phoneNumber: phoneNumber, }); } catch (error) { - console.error("Verify OTP error:", error); trackApiError( request, "/api/phone/verify-otp", diff --git a/app/components/KycModal.tsx b/app/components/KycModal.tsx index 2fde0002..d1bf06fa 100644 --- a/app/components/KycModal.tsx +++ b/app/components/KycModal.tsx @@ -4,13 +4,19 @@ import { toast } from "sonner"; import { usePrivy, useWallets } from "@privy-io/react-auth"; import { motion, AnimatePresence } from "framer-motion"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; declare global { namespace JSX { interface IntrinsicElements { "smart-camera-web": any; } } + interface Window { + tf?: { + load: () => void; + reload: () => void; + }; + } } import { @@ -64,6 +70,8 @@ export const STEPS = { TIER3_PROMPT: "tier3_prompt", TIER3_COUNTRY: "tier3_country", TIER3_UPLOAD: "tier3_upload", + // Tier 4 (business verification) flow + TIER4_TYPEFORM: "tier4_typeform", } as const; type Step = @@ -75,7 +83,8 @@ type Step = | (typeof STEPS.STATUS)[keyof typeof STEPS.STATUS] | typeof STEPS.TIER3_PROMPT | typeof STEPS.TIER3_COUNTRY - | typeof STEPS.TIER3_UPLOAD; + | typeof STEPS.TIER3_UPLOAD + | typeof STEPS.TIER4_TYPEFORM; // Types for ID types JSON type IdType = { @@ -111,7 +120,7 @@ export const KycModal = ({ }: { setIsUserVerified: (value: boolean) => void; setIsKycModalOpen: (value: boolean) => void; - targetTier?: 2 | 3; + targetTier?: 2 | 3 | 4; }) => { const { getAccessToken, user } = usePrivy(); const { wallets } = useWallets(); @@ -125,7 +134,11 @@ export const KycModal = ({ : embeddedWallet?.address; const [step, setStep] = useState(() => - targetTier === 3 ? STEPS.TIER3_PROMPT : STEPS.LOADING, + targetTier === 4 + ? STEPS.TIER4_TYPEFORM + : targetTier === 3 + ? STEPS.TIER3_PROMPT + : STEPS.LOADING, ); const [termsAccepted, setTermsAccepted] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); @@ -149,7 +162,9 @@ export const KycModal = ({ const [tier3UploadedFile, setTier3UploadedFile] = useState(null); const [tier3RequirementsOpen, setTier3RequirementsOpen] = useState(false); const [tier3Submitting, setTier3Submitting] = useState(false); - const [tier3ErrorMessage, setTier3ErrorMessage] = useState(null); + const [tier3ErrorMessage, setTier3ErrorMessage] = useState( + null, + ); const { refreshStatus } = useKYC(); const countries = getAllCountries(); const tier3CountryOptions = countries.map((c) => ({ @@ -177,18 +192,18 @@ export const KycModal = ({ ); useEffect(() => { + // Only load SmileID components for Tier 2 verification flow + if (targetTier === 3 || targetTier === 4) return; if (typeof window !== "undefined" && !smileIdLoaded) { import("@smileid/web-components/smart-camera-web") .then(() => { - console.log("SmileID web components loaded"); setSmileIdLoaded(true); }) - .catch((error) => { - console.error("Failed to load SmileID components:", error); + .catch(() => { toast.error("Failed to load verification component"); }); } - }, [smileIdLoaded]); + }, [smileIdLoaded, targetTier]); const handleAcceptTerms = () => { setIsKycModalOpen(true); @@ -530,7 +545,10 @@ export const KycModal = ({ @@ -556,7 +574,8 @@ export const KycModal = ({
    -
    +
    {tier3CountryCode != "" && ( -
    +
    -
    +

    @@ -942,61 +965,77 @@ export const KycModal = ({

    {tier3DocumentType && ( -
    -
    - - -
    -
    e.preventDefault()} - className={classNames( - "flex flex-col items-start px-4 justify-center gap-2 rounded-2xl border-[1.5px] border-dashed border-white/5 py-3 bg-transparent", - tier3UploadedFile ? "border-lavender-500/50" : "", - )} - > -
    - {tier3UploadedFile ? : } -
    - {tier3UploadedFile ? ( - <> -

    - {tier3UploadedFile.name} change -

    -

    - Size: {(tier3UploadedFile.size / 1024 / 1024).toFixed(2)} MB Format: {String(tier3UploadedFile.type?.split("/")[1] ?? tier3UploadedFile.name?.split(".").pop() ?? "UNKNOWN").toUpperCase()} -

    - - ) : ( - <> -

    - Drag and drop or{" "} -

    +
    + -

    -

    - JPG, PNG, PDF, DOC allowed. 5MB Max. -

    - - )} - {tier3ErrorMessage && ( -

    {tier3ErrorMessage}

    - )} + +
    +
    e.preventDefault()} + className={classNames( + "flex flex-col items-start justify-center gap-2 rounded-2xl border-[1.5px] border-dashed border-white/5 bg-transparent px-4 py-3", + tier3UploadedFile ? "border-lavender-500/50" : "", + )} + > +
    + {tier3UploadedFile ? ( + + ) : ( + + )} +
    + {tier3UploadedFile ? ( + <> +

    + {tier3UploadedFile.name}{" "} + {" "} + + change + +

    +

    + Size: {(tier3UploadedFile.size / 1024 / 1024).toFixed(2)} MB + Format:{" "} + {String( + tier3UploadedFile.type?.split("/")[1] ?? + tier3UploadedFile.name?.split(".").pop() ?? + "UNKNOWN", + ).toUpperCase()} +

    + + ) : ( + <> +

    + Drag and drop or{" "} + +

    +

    + JPG, PNG, PDF, DOC allowed. 5MB Max. +

    + + )} + {tier3ErrorMessage && ( +

    + {tier3ErrorMessage} +

    + )}
    )} @@ -1035,7 +1074,7 @@ export const KycModal = ({ body: formData, }); const data = await res.json(); - if (data.status === "success") { + if (data.success) { await refreshStatus(); setIsUserVerified(true); setStep(STEPS.STATUS.SUCCESS); @@ -1062,8 +1101,88 @@ export const KycModal = ({ setIsRefreshing(false); }; + // Tier 4: Typeform live embed + const typeformContainerRef = useRef(null); + const typeformScriptLoaded = useRef(false); + const [isTypeformReady, setIsTypeformReady] = useState(false); + + useEffect(() => { + if (step !== STEPS.TIER4_TYPEFORM) return; + setIsTypeformReady(false); + + const loadScript = () => { + if (typeformScriptLoaded.current && window.tf) { + window.tf.load(); + return; + } + + const existing = document.querySelector( + 'script[src*="embed.typeform.com/next/embed.js"]', + ); + if (existing) { + typeformScriptLoaded.current = true; + window.tf?.load(); + return; + } + + const script = document.createElement("script"); + script.src = "//embed.typeform.com/next/embed.js"; + script.async = true; + script.onload = () => { + typeformScriptLoaded.current = true; + }; + document.head.appendChild(script); + }; + + const scriptTimer = setTimeout(loadScript, 100); + const spinnerTimer = setTimeout(() => setIsTypeformReady(true), 2000); + + return () => { + clearTimeout(scriptTimer); + clearTimeout(spinnerTimer); + }; + }, [step]); + + const renderTier4Typeform = () => ( + +
    + +
    +

    + Business verification +

    +

    + Complete this form to apply for unlimited transaction limits. +

    +
    +
    + + {!isTypeformReady && ( +
    +
    +
    + )} + +
    + +
    +
    + + ); + const fetchStatus = async () => { - if (!walletAddress || targetTier === 3) return; + if (!walletAddress || targetTier === 3 || targetTier === 4) return; try { const response = await fetchKYCStatus(walletAddress); @@ -1226,10 +1345,6 @@ export const KycModal = ({ setStep(STEPS.STATUS.FAILED); } } catch (error) { - if (error instanceof Error) { - console.error("Error message:", error.message); - console.error("Error stack:", error.stack); - } toast.error("Failed to submit verification data"); setStep(STEPS.STATUS.FAILED); } @@ -1287,6 +1402,7 @@ export const KycModal = ({ [STEPS.TIER3_PROMPT]: renderTier3Prompt(), [STEPS.TIER3_COUNTRY]: renderTier3Country(), [STEPS.TIER3_UPLOAD]: renderTier3Upload(), + [STEPS.TIER4_TYPEFORM]: renderTier4Typeform(), }[step] } diff --git a/app/components/PhoneVerificationModal.tsx b/app/components/PhoneVerificationModal.tsx index 9f6c7f0d..2d469e7b 100644 --- a/app/components/PhoneVerificationModal.tsx +++ b/app/components/PhoneVerificationModal.tsx @@ -88,8 +88,7 @@ export default function PhoneVerificationModal({ setSelectedCountry(nigeria); } }) - .catch((error) => { - console.error("Failed to load countries:", error); + .catch(() => { toast.error("Failed to load countries. Using defaults."); }) .finally(() => { @@ -192,7 +191,6 @@ export default function PhoneVerificationModal({ toast.error(data.error || "Failed to send OTP"); } } catch (error) { - console.error("Phone submission error:", error); toast.error("Failed to send OTP. Please try again."); } finally { setIsLoading(false); @@ -232,7 +230,6 @@ export default function PhoneVerificationModal({ } } } catch (error) { - console.error("OTP verification error:", error); toast.error("Failed to verify OTP. Please try again."); } finally { setIsLoading(false); diff --git a/app/components/ProfileDrawer.tsx b/app/components/ProfileDrawer.tsx index 317bb90b..d28062f6 100644 --- a/app/components/ProfileDrawer.tsx +++ b/app/components/ProfileDrawer.tsx @@ -32,7 +32,6 @@ export default function ProfileDrawer({ isOpen, onClose }: ProfileDrawerProps) { const { user } = usePrivy(); const { tier, - isFullyVerified, transactionSummary, getCurrentLimits, refreshStatus, @@ -195,13 +194,9 @@ export default function ProfileDrawer({ isOpen, onClose }: ProfileDrawerProps) { ) : req.includes("Selfie verification") ? ( ) : req.includes("Address verification") ? ( - + ) : req.includes("Business verification") ? ( - + ) : ( req.includes("ID") && ( @@ -217,14 +212,16 @@ export default function ProfileDrawer({ isOpen, onClose }: ProfileDrawerProps) { Limit

    - { tierData.limits.unlimited ? "Unlimited" : ( - <> - - ${formatNumberWithCommas(tierData.limits.monthly)} - {" "} - / month - - )} + {tierData.limits.unlimited ? ( + "Unlimited" + ) : ( + <> + + ${formatNumberWithCommas(tierData.limits.monthly)} + {" "} + / month + + )}

    {tier == 0 && tierLevel === tier + 1 && ( @@ -377,9 +374,9 @@ export default function ProfileDrawer({ isOpen, onClose }: ProfileDrawerProps) {
    {/* Progress Bar */} -
    +
    { // Get embedded wallet (EOA) and smart wallet (SCW) const embeddedWallet = wallets.find( - (wallet) => wallet.walletClientType === "privy" + (wallet) => wallet.walletClientType === "privy", ); const smartWallet = user?.linkedAccounts.find( - (account) => account.type === "smart_wallet" + (account) => account.type === "smart_wallet", ); // Determine active wallet based on migration status @@ -287,7 +287,7 @@ export const SettingsDropdown = () => {

    Export wallet

  • - )} */} + )}
  • {

    Profile

  • - )} + {!isInjectedWallet && (
  • Your current monthly limit is{" "} - + ${formatNumberWithCommas(currentLimits.monthly)} . Verify your identity to unlock higher limits. @@ -118,9 +118,9 @@ export default function TransactionLimitModal({ {/* Progress Bar */} -

    +
    0 @@ -158,7 +158,7 @@ export default function TransactionLimitModal({ You're currently at {currentTier?.name} with $ {formatNumberWithCommas(currentLimits.monthly)}/month.{" "} {nextTier - ? `Upgrade to ${nextTier.name} for ${nextTier.limits.unlimited ? "Unlimited" : `$${formatNumberWithCommas(nextTier.limits.monthly)}/month`}` + ? `Upgrade to ${nextTier.name} for ${nextTier.limits.unlimited ? "Unlimited transactions" : `$${formatNumberWithCommas(nextTier.limits.monthly)}/month`}` : "You have the highest tier available"} .{" "} @@ -236,13 +236,16 @@ export default function TransactionLimitModal({ }} > { + setIsKycModalOpen(value); + if (!value) onClose(); + }} setIsUserVerified={async () => { await refreshStatus(); setIsKycModalOpen(false); onClose(); }} - targetTier={tier === 2 ? 3 : 2} + targetTier={tier === 3 ? 4 : tier === 2 ? 3 : 2} /> )} diff --git a/app/context/KYCContext.tsx b/app/context/KYCContext.tsx index 229ac0bb..98882b9d 100644 --- a/app/context/KYCContext.tsx +++ b/app/context/KYCContext.tsx @@ -24,13 +24,19 @@ export interface TransactionLimits { export type KYCTierLevel = 0 | 1 | 2 | 3 | 4; export interface KYCTier { - level: 1 | 2 | 3 | 4; + level: 0 | 1 | 2 | 3 | 4; name: string; limits: TransactionLimits; requirements: string[]; } export const KYC_TIERS: Record = { + 0: { + level: 0, + name: "Tier 0", + limits: { monthly: 0 }, + requirements: [], + }, 1: { level: 1, name: "Tier 1", @@ -67,7 +73,6 @@ interface KYCContextType { tier: KYCTierLevel; isPhoneVerified: boolean; phoneNumber: string | null; - isFullyVerified: boolean; transactionSummary: UserTransactionSummary; canTransact: (amount: number) => { allowed: boolean; reason?: string }; getCurrentLimits: () => TransactionLimits; @@ -87,12 +92,13 @@ export function KYCProvider({ children }: { children: React.ReactNode }) { // In-memory guards for fetches per wallet const fetchGuardsRef = useRef>({}); + const lastFetchTimeRef = useRef(0); + const STALENESS_WINDOW_MS = 30_000; // 30 seconds const guardKey = walletAddress || "no_wallet"; const [tier, setTier] = useState(0); const [isPhoneVerified, setIsPhoneVerified] = useState(false); const [phoneNumber, setPhoneNumber] = useState(null); - const [isFullyVerified, setIsFullyVerified] = useState(false); const [transactionSummary, setTransactionSummary] = useState({ dailySpent: 0, @@ -101,14 +107,11 @@ export function KYCProvider({ children }: { children: React.ReactNode }) { }); const getCurrentLimits = useCallback((): TransactionLimits => { - if (tier > 0) { - return KYC_TIERS[tier].limits; - } - return { monthly: 0 }; + return KYC_TIERS[tier].limits; }, [tier]); const getRemainingLimits = useCallback((): TransactionLimits => { - const currentLimits = tier > 0 ? KYC_TIERS[tier].limits : { monthly: 0 }; + const currentLimits = KYC_TIERS[tier].limits; const remaining = Math.max( 0, currentLimits.monthly - transactionSummary.monthlySpent, @@ -140,19 +143,18 @@ export function KYCProvider({ children }: { children: React.ReactNode }) { if (guards[key] === "fetching") return; guards[key] = "fetching"; try { + const accessToken = await getAccessToken(); + if (!accessToken) return; + const response = await fetch( - `/api/kyc/transaction-summary?walletAddress=${encodeURIComponent(walletAddress)}`, + `/api/kyc/transaction-summary`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, ); - if (!response.ok) { - let body: unknown; - try { - body = await response.json(); - } catch { - body = await response.text(); - } - console.error("Transaction summary fetch failed:", response.status, response.statusText, body); - return; - } + if (!response.ok) return; const data = await response.json(); if (data.success) { setTransactionSummary({ @@ -160,15 +162,13 @@ export function KYCProvider({ children }: { children: React.ReactNode }) { monthlySpent: data.monthlySpent, lastTransactionDate: data.lastTransactionDate, }); - } else { - console.error("Error fetching transaction summary:", data.error); } - } catch (error) { - console.error("Error calculating transaction summary:", error); + } catch { + // Silently fail โ€” analytics tracked server-side } finally { guards[key] = "done"; } - }, [walletAddress, guardKey]); + }, [walletAddress, guardKey, getAccessToken]); const fetchKYCStatus = useCallback(async () => { if (!walletAddress) return; @@ -178,48 +178,38 @@ export function KYCProvider({ children }: { children: React.ReactNode }) { guards[key] = "fetching"; try { const accessToken = await getAccessToken(); - if (!accessToken) { - console.error("Failed to get access token for KYC status"); - return; - } + if (!accessToken) return; const response = await fetch(`/api/kyc/status`, { headers: { Authorization: `Bearer ${accessToken}`, }, }); - if (!response.ok) { - let body: unknown; - try { - body = await response.json(); - } catch { - body = await response.text(); - } - console.error("KYC status fetch failed:", response.status, response.statusText, body); - return; - } + if (!response.ok) return; const data = await response.json(); if (data.success) { setTier(data.tier); setIsPhoneVerified(data.isPhoneVerified); setPhoneNumber(data.phoneNumber); - setIsFullyVerified(data.isFullyVerified); - } else { - console.error("Error fetching KYC status:", data.error); } - } catch (error) { - console.error("Error fetching KYC status:", error); + } catch { + // Silently fail โ€” analytics tracked server-side } finally { guards[key] = "done"; } }, [walletAddress, getAccessToken, guardKey]); - const refreshStatus = useCallback(async () => { + const refreshStatus = useCallback(async (force = false) => { + // Skip refresh if data is fresh (within staleness window) + const now = Date.now(); + if (!force && now - lastFetchTimeRef.current < STALENESS_WINDOW_MS) return; + // Reset guards so fetches can run again const guards = fetchGuardsRef.current; delete guards[`${guardKey}_kyc`]; delete guards[`${guardKey}_tx`]; await Promise.all([fetchKYCStatus(), fetchTransactionSummary()]); + lastFetchTimeRef.current = Date.now(); }, [fetchKYCStatus, fetchTransactionSummary, guardKey]); // Initial load and wallet address change @@ -227,7 +217,8 @@ export function KYCProvider({ children }: { children: React.ReactNode }) { if (walletAddress) { // Reset guards when wallet changes fetchGuardsRef.current = {}; - refreshStatus(); + lastFetchTimeRef.current = 0; + refreshStatus(true); } }, [walletAddress, refreshStatus]); @@ -237,7 +228,6 @@ export function KYCProvider({ children }: { children: React.ReactNode }) { tier, isPhoneVerified, phoneNumber, - isFullyVerified, transactionSummary, canTransact, getCurrentLimits, diff --git a/app/types.ts b/app/types.ts index 69db3091..8be95cab 100644 --- a/app/types.ts +++ b/app/types.ts @@ -245,10 +245,9 @@ export type InitiateKYCResponse = { export type SmileIDSubmissionResponse = { status: string; message: string; - data: { + data?: { jobId: string; userId: string; - smileIdResponse: any; }; }; diff --git a/middleware.ts b/middleware.ts index 09e57fc1..142d682a 100644 --- a/middleware.ts +++ b/middleware.ts @@ -266,6 +266,7 @@ export const config = { "/api/blockfest/cashback", "/api/kyc/smile-id", "/api/kyc/status", + "/api/kyc/transaction-summary", "/api/kyc/tier3-verify", "/api/phone/send-otp", "/api/phone/verify-otp", From aeb9a78bcea8b0ec0e86be93bce0ffb7066dd448 Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Wed, 11 Feb 2026 09:00:46 +0100 Subject: [PATCH 22/22] fix: Correct typo in consent message for phone verification --- app/components/PhoneVerificationModal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/PhoneVerificationModal.tsx b/app/components/PhoneVerificationModal.tsx index 2d469e7b..219c76bc 100644 --- a/app/components/PhoneVerificationModal.tsx +++ b/app/components/PhoneVerificationModal.tsx @@ -427,9 +427,9 @@ export default function PhoneVerificationModal({

    - By clicking "Verify and start", you consent to recieving transactional - text messages for notifications and alerts from Noblocks. Reply STOP - to opt out. you agree to our{" "} + By clicking "Verify and start", you consent to recieving + transactional text messages for notifications and alerts from + Noblocks. Reply STOP to opt out. you agree to our{" "}