diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 3e279e2a0..5cd935e3a 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -1045,6 +1045,7 @@ export default function QRPayPage() { exchange_rate: currency.price.toString(), }, }, + totalAmountCollected: Number(usdAmount), }) }} > @@ -1126,6 +1127,7 @@ export default function QRPayPage() { exchange_rate: currency.price.toString(), }, }, + totalAmountCollected: Number(usdAmount), }) }} > diff --git a/src/app/(mobile-ui)/request/create/page.tsx b/src/app/(mobile-ui)/request/create/page.tsx deleted file mode 100644 index 6e05bd931..000000000 --- a/src/app/(mobile-ui)/request/create/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { generateMetadata } from '@/app/metadata' -import PageContainer from '@/components/0_Bruddle/PageContainer' -import { CreateRequestLinkView } from '@/components/Request/link/views/Create.request.link.view' - -export const metadata = generateMetadata({ - title: 'Request Payment | Peanut', - description: 'Request cryptocurrency from friends, family, or anyone else using Peanut on any chain.', - image: '/metadata-img.png', - keywords: 'crypto request, crypto payment, crypto invoice, crypto payment link', -}) - -export default function RequestCreate() { - return ( - - - - ) -} diff --git a/src/app/(mobile-ui)/request/page.tsx b/src/app/(mobile-ui)/request/page.tsx index 2276df456..d870334c9 100644 --- a/src/app/(mobile-ui)/request/page.tsx +++ b/src/app/(mobile-ui)/request/page.tsx @@ -1,6 +1,6 @@ import { generateMetadata } from '@/app/metadata' import PageContainer from '@/components/0_Bruddle/PageContainer' -import { RequestRouterView } from '@/components/Request/views/RequestRouter.view' +import { CreateRequestLinkView } from '@/components/Request/link/views/Create.request.link.view' export const metadata = generateMetadata({ title: 'Request Money | Peanut', @@ -13,7 +13,7 @@ export const metadata = generateMetadata({ export default function RequestPage() { return ( - + ) } diff --git a/src/app/[...recipient]/client.tsx b/src/app/[...recipient]/client.tsx index c253a8c27..6981d257e 100644 --- a/src/app/[...recipient]/client.tsx +++ b/src/app/[...recipient]/client.tsx @@ -521,9 +521,10 @@ export default function PaymentPage({ recipient, flow = 'request_pay' }: Props) } setCurrencyAmount={(value: string | undefined) => setCurrencyAmount(value || '')} currencyAmount={currencyAmount} + showRequestPotInitialView={!!requestId} />
- {showActionList && ( + {!requestId && showActionList && ( + + + + + + + diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index 42b8b0e8e..613f6a642 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -21,3 +21,5 @@ export { default as STAR_STRAIGHT_ICON } from './starStraight.svg' export { default as WHATSAPP_ICON } from './whatsapp.svg' export { default as IMESSAGE_ICON } from './imessage.svg' export { default as FBMessenger_ICON } from './fbmessenger.svg' +export { default as SOCIALS_ICON } from './socials.svg' +export { default as COIN_ICON } from './coin.svg' diff --git a/src/assets/icons/socials.svg b/src/assets/icons/socials.svg new file mode 100644 index 000000000..07e5ed69b --- /dev/null +++ b/src/assets/icons/socials.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Global/Badges/StatusBadge.tsx b/src/components/Global/Badges/StatusBadge.tsx index 963786ff4..55c3ff751 100644 --- a/src/components/Global/Badges/StatusBadge.tsx +++ b/src/components/Global/Badges/StatusBadge.tsx @@ -1,7 +1,7 @@ import React from 'react' import { twMerge } from 'tailwind-merge' -export type StatusType = 'completed' | 'pending' | 'failed' | 'cancelled' | 'soon' | 'processing' | 'custom' +export type StatusType = 'completed' | 'pending' | 'failed' | 'cancelled' | 'soon' | 'processing' | 'custom' | 'closed' interface StatusBadgeProps { status: StatusType @@ -14,6 +14,7 @@ const StatusBadge: React.FC = ({ status, className, size = 'sm const getStatusStyles = () => { switch (status) { case 'completed': + case 'closed': return 'bg-success-2 text-success-4 border border-success-5' case 'pending': case 'processing': @@ -43,6 +44,8 @@ const StatusBadge: React.FC = ({ status, className, size = 'sm return 'Cancelled' case 'soon': return 'Soon!' + case 'closed': + return 'Closed' case 'custom': return customText default: diff --git a/src/components/Global/Contributors/ContributorCard.tsx b/src/components/Global/Contributors/ContributorCard.tsx new file mode 100644 index 000000000..d351b9e9f --- /dev/null +++ b/src/components/Global/Contributors/ContributorCard.tsx @@ -0,0 +1,46 @@ +import { type Payment } from '@/services/services.types' +import Card, { type CardPosition } from '../Card' +import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' +import { getColorForUsername } from '@/utils/color.utils' +import { VerifiedUserLabel } from '@/components/UserHeader' +import { formatTokenAmount } from '@/utils' +import { isAddress } from 'viem' + +export type Contributor = { + uuid: string + payments: Payment[] + amount: string + username: string | undefined + fulfillmentPayment: Payment | null + isUserVerified: boolean +} + +const ContributorCard = ({ contributor, position }: { contributor: Contributor; position: CardPosition }) => { + const colors = getColorForUsername(contributor.username ?? '') + const isEvmAddress = isAddress(contributor.username ?? '') + return ( + +
+
+ + + +
+ +

${formatTokenAmount(Number(contributor.amount))}

+
+
+ ) +} + +export default ContributorCard diff --git a/src/components/Global/Contributors/index.tsx b/src/components/Global/Contributors/index.tsx new file mode 100644 index 000000000..a8844fa7c --- /dev/null +++ b/src/components/Global/Contributors/index.tsx @@ -0,0 +1,24 @@ +import { type ChargeEntry } from '@/services/services.types' +import { getContributorsFromCharge } from '@/utils' +import React from 'react' +import ContributorCard from './ContributorCard' +import { getCardPosition } from '../Card' + +const Contributors = ({ charges }: { charges: ChargeEntry[] }) => { + const contributors = getContributorsFromCharge(charges) + + return ( +
+

Contributors ({contributors.length})

+ {contributors.map((contributor, index) => ( + + ))} +
+ ) +} + +export default Contributors diff --git a/src/components/Global/Icons/Icon.tsx b/src/components/Global/Icons/Icon.tsx index 126e235fd..973a3b7c8 100644 --- a/src/components/Global/Icons/Icon.tsx +++ b/src/components/Global/Icons/Icon.tsx @@ -63,6 +63,7 @@ import { ShieldIcon } from './shield' import { TrophyIcon } from './trophy' import { InviteHeartIcon } from './invite-heart' import { LockIcon } from './lock' +import { SplitIcon } from './split' // available icon names export type IconName = @@ -130,6 +131,7 @@ export type IconName = | 'trophy' | 'invite-heart' | 'lock' + | 'split' export interface IconProps extends SVGProps { name: IconName size?: number | string @@ -201,6 +203,7 @@ const iconComponents: Record>> = trophy: TrophyIcon, 'invite-heart': InviteHeartIcon, lock: LockIcon, + split: SplitIcon, } export const Icon: FC = ({ name, size = 24, width, height, ...props }) => { diff --git a/src/components/Global/Icons/split.tsx b/src/components/Global/Icons/split.tsx new file mode 100644 index 000000000..218046f25 --- /dev/null +++ b/src/components/Global/Icons/split.tsx @@ -0,0 +1,12 @@ +import { type FC, type SVGProps } from 'react' + +export const SplitIcon: FC> = (props) => { + return ( + + + + ) +} diff --git a/src/components/Global/PeanutActionCard/index.tsx b/src/components/Global/PeanutActionCard/index.tsx index 237b4a325..db74adff6 100644 --- a/src/components/Global/PeanutActionCard/index.tsx +++ b/src/components/Global/PeanutActionCard/index.tsx @@ -1,5 +1,7 @@ +import Image from 'next/image' import Card from '../Card' import { Icon } from '../Icons/Icon' +import { SOCIALS_ICON } from '@/assets' interface PeanutActionCardProps { type: 'request' | 'send' @@ -7,16 +9,22 @@ interface PeanutActionCardProps { const PeanutActionCard = ({ type }: PeanutActionCardProps) => { return ( - +
-
- {type === 'request' ? 'Request money with a link' : 'Create a payment link'} +
+ {type === 'request' ? 'Request money from friends' : 'Create a payment link'}
-
- {type === 'request' ? `No account needed to pay ` : 'Anyone with the link can receive the money'} +
+ {type === 'request' + ? `They don't need an account to pay` + : 'Anyone with the link can receive the money'} +
+
+ Socials +

Perfect for group chats!

diff --git a/src/components/Global/ProgressBar/index.tsx b/src/components/Global/ProgressBar/index.tsx new file mode 100644 index 000000000..2c13b127f --- /dev/null +++ b/src/components/Global/ProgressBar/index.tsx @@ -0,0 +1,153 @@ +import { COIN_ICON } from '@/assets' +import Image from 'next/image' +import React from 'react' +import { twMerge } from 'tailwind-merge' + +interface ProgressBarProps { + goal: number + progress: number + isClosed: boolean +} + +const ProgressBar: React.FC = ({ goal, progress, isClosed }) => { + const isOverGoal = progress > goal && goal > 0 + const isGoalAchieved = progress >= goal && !isOverGoal && goal > 0 + const totalValue = isOverGoal ? progress : goal + + // Guard against division by zero and clamp percentages to valid ranges + const goalPercentage = totalValue > 0 ? Math.min(Math.max((goal / totalValue) * 100, 0), 100) : 0 + const progressPercentage = totalValue > 0 ? Math.min(Math.max((progress / totalValue) * 100, 0), 100) : 0 + const percentage = goal > 0 ? Math.min(Math.max(Math.round((progress / goal) * 100), 0), 100) : 0 + + const formatCurrency = (value: number) => `$${value.toFixed(2)}` + + const getStatusText = () => { + if (isOverGoal) return 'Goal exceeded!' + if (isGoalAchieved) return 'Goal achieved!' + return `${100 - percentage}% below goal` + } + + const getBackgroundColor = () => { + if (!isClosed) return 'bg-grey-2' + return isGoalAchieved ? 'bg-success-3' : 'bg-error-4' + } + + const renderStatusText = () => { + if (!isClosed) return null + return ( +
+ coin +

{getStatusText()}

+
+ ) + } + + const renderLabels = () => { + if (!isClosed) { + return ( +
+

{formatCurrency(progress)} contributed

+

{formatCurrency(Math.max(goal - progress, 0))} remaining

+
+ ) + } + + if (isOverGoal) { + return ( +
+

+ 100% +

+

{formatCurrency(progress)}

+
+ ) + } + + if (isGoalAchieved) return null + + return ( +
+

+ {formatCurrency(progress)} +

+
+

100%

+
+
+ ) + } + + const renderMarker = (color: string, position: string | number, isPercentage = true) => { + const positionStyle = typeof position === 'string' ? { left: position } : { left: `${position}%` } + return ( +
+
+
+ ) + } + + const renderGoalMarker = () => { + const markerColor = isGoalAchieved ? 'bg-success-3' : 'bg-error-4' + const containerClasses = twMerge( + 'absolute top-1/2 z-10 -translate-y-1/2 transition-all duration-300', + isGoalAchieved ? 'right-0' : '-translate-x-1/2' + ) + const containerStyle = isGoalAchieved ? {} : { left: '100%' } + + return ( +
+ {isGoalAchieved &&

100%

} +
+
+ ) + } + + const renderProgressBar = () => { + const barBaseClasses = 'absolute left-0 h-1.5 rounded-full transition-all duration-300 ease-in-out' + + if (isOverGoal) { + return ( + <> +
+
+ {isClosed && ( + <> + {renderMarker('bg-success-3', goalPercentage)} + {renderMarker('bg-yellow-1', '100%', false)} + + )} + + ) + } + + return ( + <> +
+
+ {isClosed && ( + <> + {!isGoalAchieved && renderMarker('bg-success-3', progressPercentage)} + {renderGoalMarker()} + + )} + + ) + } + + return ( +
+ {renderStatusText()} + {renderLabels()} + +
{renderProgressBar()}
+
+ ) +} + +export default ProgressBar diff --git a/src/components/Global/QRCodeWrapper/index.tsx b/src/components/Global/QRCodeWrapper/index.tsx index a0389cd08..37fad75bf 100644 --- a/src/components/Global/QRCodeWrapper/index.tsx +++ b/src/components/Global/QRCodeWrapper/index.tsx @@ -12,7 +12,7 @@ interface QRCodeWrapperProps { const QRCodeWrapper = ({ url, isLoading = false, disabled = false }: QRCodeWrapperProps) => { return ( -
+
{/* Container with black border and rounded corners */}
) { + // Use internal state for the slider value to enable magnetic snapping + const [internalValue, setInternalValue] = React.useState(controlledValue || defaultValue) + + // Sync with controlled value if it changes externally + React.useEffect(() => { + if (controlledValue) { + setInternalValue(controlledValue) + } + }, [controlledValue]) + + // Check if current value is at a snap point (exact match) + const activeSnapPoint = React.useMemo(() => { + return SNAP_POINTS.find((snapPoint) => Math.abs(internalValue[0] - snapPoint) < 0.5) + }, [internalValue]) + + // Soft snap to nearby snap points with ±5% threshold + const handleValueChange = React.useCallback( + (newValue: number[]) => { + const rawValue = newValue[0] + let finalValue = rawValue + + // Check if we're within snap threshold of any snap point + for (const snapPoint of SNAP_POINTS) { + if (Math.abs(rawValue - snapPoint) <= SNAP_THRESHOLD) { + finalValue = snapPoint + break + } + } + + const finalArray = [finalValue] + + // Only update if the value actually changed + if (internalValue[0] !== finalValue) { + setInternalValue(finalArray) + onValueChange?.(finalArray) + } + }, + [onValueChange, internalValue] + ) + + return ( +
+
+

0%

+

120%

+
+ + + + + + + {/* Vertical tick mark - only visible when at a snap point */} + {activeSnapPoint !== undefined && ( +
+ )} + + {/* White circle with border on top of the tick */} +
+ + {/* Current value label */} +
+ {internalValue[0] % 1 === 0 ? internalValue[0].toFixed(0) : internalValue[0].toFixed(2)}% +
+ + +
+ ) +} + +export { Slider } diff --git a/src/components/Global/StatusPill/index.tsx b/src/components/Global/StatusPill/index.tsx index 1daec7db8..89a986a07 100644 --- a/src/components/Global/StatusPill/index.tsx +++ b/src/components/Global/StatusPill/index.tsx @@ -16,6 +16,7 @@ const StatusPill = ({ status }: StatusPillProps) => { failed: 'border-error-2 bg-error-1 text-error', processing: 'border-yellow-8 bg-secondary-4 text-yellow-6', soon: 'border-yellow-8 bg-secondary-4 text-yellow-6', + closed: 'border-success-5 bg-success-2 text-success-4', } const iconClasses: Record = { @@ -25,21 +26,23 @@ const StatusPill = ({ status }: StatusPillProps) => { soon: 'pending', pending: 'pending', cancelled: 'cancel', + closed: 'success', } const iconSize: Record = { - completed: 10, - failed: 7, + completed: 7, + failed: 6, processing: 10, - soon: 10, - pending: 10, - cancelled: 7, + soon: 7, + pending: 8, + cancelled: 6, + closed: 7, } return (
diff --git a/src/components/Global/TokenAmountInput/index.tsx b/src/components/Global/TokenAmountInput/index.tsx index 25f011078..68fb25d7d 100644 --- a/src/components/Global/TokenAmountInput/index.tsx +++ b/src/components/Global/TokenAmountInput/index.tsx @@ -4,6 +4,8 @@ import { formatAmountWithoutComma, formatTokenAmount, formatCurrency } from '@/u import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import Icon from '../Icon' import { twMerge } from 'tailwind-merge' +import { Icon as IconComponent } from '@/components/Global/Icons/Icon' +import { Slider } from '../Slider' interface TokenAmountInputProps { className?: string @@ -22,6 +24,10 @@ interface TokenAmountInputProps { } hideCurrencyToggle?: boolean hideBalance?: boolean + showInfoText?: boolean + infoText?: string + showSlider?: boolean + maxAmount?: number isInitialInputUsd?: boolean } @@ -38,6 +44,10 @@ const TokenAmountInput = ({ setUsdValue, hideCurrencyToggle = false, hideBalance = false, + infoText, + showInfoText, + showSlider = false, + maxAmount, isInitialInputUsd = false, }: TokenAmountInputProps) => { const { selectedTokenData } = useContext(tokenSelectorContext) @@ -128,6 +138,17 @@ const TokenAmountInput = ({ [displayMode, currency?.price, selectedTokenData?.price, calculateAlternativeValue] ) + const onSliderValueChange = useCallback( + (value: number[]) => { + if (maxAmount) { + const selectedPercentage = value[0] + const selectedAmount = parseFloat(((selectedPercentage / 100) * maxAmount).toFixed(4)).toString() + onChange(selectedAmount, isInputUsd) + } + }, + [maxAmount, onChange] + ) + const showConversion = useMemo(() => { return !hideCurrencyToggle && (displayMode === 'TOKEN' || displayMode === 'FIAT') }, [hideCurrencyToggle, displayMode]) @@ -194,6 +215,13 @@ const TokenAmountInput = ({ const formRef = useRef(null) + const sliderValue = useMemo(() => { + if (!maxAmount || !tokenValue) return [0] + const tokenNum = parseFloat(tokenValue.replace(/,/g, '')) + const usdValue = tokenNum * (selectedTokenData?.price ?? 1) + return [(usdValue / maxAmount) * 100] + }, [maxAmount, tokenValue, selectedTokenData?.price]) + const handleContainerClick = () => { if (inputRef.current) { inputRef.current.focus() @@ -203,7 +231,7 @@ const TokenAmountInput = ({ return (
@@ -259,7 +287,6 @@ const TokenAmountInput = ({
)}
- {/* Conversion toggle */} {showConversion && (
)} + {showInfoText && infoText && ( +
+ +

{infoText}

+
+ )} + {showSlider && maxAmount && ( +
+ +
+ )} ) } diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 0768af89d..9d7181d2e 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -99,7 +99,7 @@ export const KycStatusItem = ({ } export const KYCStatusIcon = () => { - return + return } export const KYCStatusDrawerItem = ({ status }: { status: StatusType }) => { diff --git a/src/components/Payment/PaymentForm/index.tsx b/src/components/Payment/PaymentForm/index.tsx index 61e87a50f..48e62ac1c 100644 --- a/src/components/Payment/PaymentForm/index.tsx +++ b/src/components/Payment/PaymentForm/index.tsx @@ -22,7 +22,7 @@ import { type ParsedURL } from '@/lib/url-parser/types/payment' import { useAppDispatch, usePaymentStore } from '@/redux/hooks' import { paymentActions } from '@/redux/slices/payment-slice' import { walletActions } from '@/redux/slices/wallet-slice' -import { areEvmAddressesEqual, ErrorHandler, formatAmount, formatCurrency } from '@/utils' +import { areEvmAddressesEqual, ErrorHandler, formatAmount, formatCurrency, getContributorsFromCharge } from '@/utils' import { useAppKit, useDisconnect } from '@reown/appkit/react' import Image from 'next/image' import { useRouter, useSearchParams } from 'next/navigation' @@ -35,6 +35,8 @@ import { type PaymentFlow } from '@/app/[...recipient]/client' import MantecaFulfillment from '../Views/MantecaFulfillment.view' import { invitesApi } from '@/services/invites' import { EInviteType } from '@/services/services.types' +import ContributorCard from '@/components/Global/Contributors/ContributorCard' +import { getCardPosition } from '@/components/Global/Card' export type PaymentFlowProps = { isExternalWalletFlow?: boolean @@ -49,6 +51,7 @@ export type PaymentFlowProps = { setCurrencyAmount?: (currencyvalue: string | undefined) => void headerTitle?: string flow?: PaymentFlow + showRequestPotInitialView?: boolean } export type PaymentFormProps = ParsedURL & PaymentFlowProps @@ -65,6 +68,7 @@ export const PaymentForm = ({ isDirectUsdPayment, headerTitle, flow, + showRequestPotInitialView, }: PaymentFormProps) => { const dispatch = useAppDispatch() const router = useRouter() @@ -222,7 +226,11 @@ export const PaymentForm = ({ } } else { // regular send/pay - if (isActivePeanutWallet && areEvmAddressesEqual(selectedTokenAddress, PEANUT_WALLET_TOKEN)) { + if ( + !showRequestPotInitialView && // don't apply balance check on request pot payment initial view + isActivePeanutWallet && + areEvmAddressesEqual(selectedTokenAddress, PEANUT_WALLET_TOKEN) + ) { // peanut wallet payment const walletNumeric = parseFloat(String(peanutWalletBalance).replace(/,/g, '')) if (walletNumeric < parsedInputAmount) { @@ -274,6 +282,7 @@ export const PaymentForm = ({ selectedTokenData, isExternalWalletConnected, isExternalWalletFlow, + showRequestPotInitialView, currentView, isProcessing, ]) @@ -323,6 +332,11 @@ export const PaymentForm = ({ const recipientExists = !!recipient const walletConnected = isConnected + // If its requestPotPayment, we only need to check if the recipient exists, amount is set, and token is selected + if (showRequestPotInitialView) { + return recipientExists && amountIsSet && tokenSelected && showRequestPotInitialView + } + return recipientExists && amountIsSet && tokenSelected && walletConnected }, [ recipient, @@ -365,7 +379,8 @@ export const PaymentForm = ({ if (inviteError) { setInviteError(false) } - if (isActivePeanutWallet && isInsufficientBalanceError && !isExternalWalletFlow) { + // Invites will be handled in the payment page, skip this step for request pots initial view + if (!showRequestPotInitialView && isActivePeanutWallet && isInsufficientBalanceError && !isExternalWalletFlow) { // If the user doesn't have app access, accept the invite before claiming the link if (recipient.recipientType === 'USERNAME' && !user?.user.hasAppAccess) { const isAccepted = await handleAcceptInvite() @@ -375,12 +390,14 @@ export const PaymentForm = ({ return } - if (!isExternalWalletConnected && isExternalWalletFlow) { + // skip this step for request pots initial view + if (!showRequestPotInitialView && !isExternalWalletConnected && isExternalWalletFlow) { openReownModal() return } - if (!isConnected) { + // skip this step for request pots initial view + if (!showRequestPotInitialView && !isConnected) { dispatch(walletActions.setSignInModalVisible(true)) return } @@ -437,6 +454,7 @@ export const PaymentForm = ({ ? 'DIRECT_SEND' : 'REQUEST', attachmentOptions: attachmentOptions, + returnAfterChargeCreation: !!showRequestPotInitialView, // For request pot initial view, return after charge creation without initiating payment } console.log('Initiating payment with payload:', payload) @@ -446,7 +464,7 @@ export const PaymentForm = ({ if (result.status === 'Success') { dispatch(paymentActions.setView('STATUS')) } else if (result.status === 'Charge Created') { - if (!fulfillUsingManteca) { + if (!fulfillUsingManteca && !showRequestPotInitialView) { dispatch(paymentActions.setView('CONFIRM')) } } else if (result.status === 'Error') { @@ -473,6 +491,7 @@ export const PaymentForm = ({ requestedTokenPrice, inviteError, handleAcceptInvite, + showRequestPotInitialView, ]) const getButtonText = () => { @@ -488,6 +507,10 @@ export const PaymentForm = ({ return 'Send' } + if (showRequestPotInitialView) { + return 'Pay' + } + if (isActivePeanutWallet && isInsufficientBalanceError && !isExternalWalletFlow) { return (
@@ -516,11 +539,13 @@ export const PaymentForm = ({ } const getButtonIcon = (): IconName | undefined => { - if (!isExternalWalletConnected && isExternalWalletFlow) return 'wallet-outline' + if (!showRequestPotInitialView && !isExternalWalletConnected && isExternalWalletFlow) return 'wallet-outline' - if (isActivePeanutWallet && isInsufficientBalanceError && !isExternalWalletFlow) return 'arrow-down' + if (!showRequestPotInitialView && isActivePeanutWallet && isInsufficientBalanceError && !isExternalWalletFlow) + return 'arrow-down' - if (!isProcessing && isActivePeanutWallet && !isExternalWalletFlow) return 'arrow-up-right' + if (!showRequestPotInitialView && !isProcessing && isActivePeanutWallet && !isExternalWalletFlow) + return 'arrow-up-right' return undefined } @@ -613,13 +638,19 @@ export const PaymentForm = ({ } } + const contributors = getContributorsFromCharge(requestDetails?.charges || []) + + const totalAmountCollected = requestDetails?.totalCollectedAmount ?? 0 + if (fulfillUsingManteca && chargeDetails) { return } return (
- + {!showRequestPotInitialView && ( + + )}
{isExternalWalletConnected && isUsingExternalWallet && (
+ + {showRequestPotInitialView && ( +
+

Contributors ({contributors.length})

+ {contributors.map((contributor, index) => ( + + ))} +
+ )} + setDisconnectWagmiModal(false)} diff --git a/src/components/Profile/AvatarWithBadge.tsx b/src/components/Profile/AvatarWithBadge.tsx index 348778681..ff21ba9b0 100644 --- a/src/components/Profile/AvatarWithBadge.tsx +++ b/src/components/Profile/AvatarWithBadge.tsx @@ -19,8 +19,6 @@ interface AvatarWithBadgeProps { inlineStyle?: React.CSSProperties // for dynamic background colors based on username (hex codes) textColor?: string iconFillColor?: string - showStatusPill?: boolean - statusPillStatus?: StatusPillType logo?: StaticImageData } @@ -36,8 +34,6 @@ const AvatarWithBadge: React.FC = ({ inlineStyle, textColor, iconFillColor, - showStatusPill, - statusPillStatus, logo, }) => { const sizeClasses: Record = { @@ -117,8 +113,6 @@ const AvatarWithBadge: React.FC = ({ initials )}
- - {showStatusPill && statusPillStatus && }
) } diff --git a/src/components/Request/link/views/Create.request.link.view.tsx b/src/components/Request/link/views/Create.request.link.view.tsx index bfac90ad3..c2c94083a 100644 --- a/src/components/Request/link/views/Create.request.link.view.tsx +++ b/src/components/Request/link/views/Create.request.link.view.tsx @@ -23,7 +23,7 @@ import { fetchTokenSymbol, getRequestLink, isNativeCurrency, printableUsdc } fro import * as Sentry from '@sentry/nextjs' import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' import { useQueryClient } from '@tanstack/react-query' -import { useRouter } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' export const CreateRequestLinkView = () => { @@ -41,11 +41,16 @@ export const CreateRequestLinkView = () => { } = useContext(context.tokenSelectorContext) const { setLoadingState } = useContext(context.loadingStateContext) const queryClient = useQueryClient() + const searchParams = useSearchParams() + const paramsAmount = searchParams.get('amount') + const sanitizedAmount = paramsAmount && !isNaN(parseFloat(paramsAmount)) ? paramsAmount : '' + const merchant = searchParams.get('merchant') + const merchantComment = merchant ? `Bill split for ${merchant}` : null // Core state - const [tokenValue, setTokenValue] = useState('') + const [tokenValue, setTokenValue] = useState(sanitizedAmount) const [attachmentOptions, setAttachmentOptions] = useState({ - message: '', + message: merchantComment || '', fileUrl: '', rawFile: undefined, }) @@ -109,15 +114,6 @@ export const CreateRequestLinkView = () => { }) return null } - - if (!tokenValue || parseFloat(tokenValue) <= 0) { - setErrorState({ - showError: true, - errorMessage: 'Please enter a token amount', - }) - return null - } - // Cleanup previous request if (createLinkAbortRef.current) { createLinkAbortRef.current.abort() @@ -169,21 +165,8 @@ export const CreateRequestLinkView = () => { const requestDetails = await requestsApi.create(requestData) setRequestId(requestDetails.uuid) - const charge = await chargesApi.create({ - pricing_type: 'fixed_price', - local_price: { - amount: requestDetails.tokenAmount, - currency: 'USD', - }, - baseUrl: BASE_URL, - requestId: requestDetails.uuid, - transactionType: 'REQUEST', - }) - const link = getRequestLink({ ...requestDetails, - uuid: undefined, - chargeId: charge.data.id, }) // Update the last saved state @@ -277,17 +260,7 @@ export const CreateRequestLinkView = () => { const handleDebouncedChange = useCallback(async () => { if (isCreatingLink || isUpdatingRequest) return - // If no request exists but we have content, create request - if (!requestId && (debouncedAttachmentOptions.rawFile || debouncedAttachmentOptions.message)) { - if (!tokenValue || parseFloat(tokenValue) <= 0) return - - const link = await createRequestLink(debouncedAttachmentOptions) - if (link) { - setGeneratedLink(link) - } - } - // If request exists and content changed (including clearing), update it - else if (requestId) { + if (requestId) { // Check for unsaved changes inline to avoid dependency issues const lastSaved = lastSavedAttachmentRef.current const hasChanges = @@ -298,15 +271,7 @@ export const CreateRequestLinkView = () => { await updateRequestLink(debouncedAttachmentOptions) } } - }, [ - debouncedAttachmentOptions, - requestId, - tokenValue, - isCreatingLink, - isUpdatingRequest, - createRequestLink, - updateRequestLink, - ]) + }, [debouncedAttachmentOptions, requestId, isCreatingLink, isUpdatingRequest, updateRequestLink]) useEffect(() => { handleDebouncedChange() @@ -355,7 +320,6 @@ export const CreateRequestLinkView = () => { const generateLink = useCallback(async () => { if (generatedLink) return generatedLink - if (Number(tokenValue) === 0) return qrCodeLink if (isCreatingLink || isUpdatingRequest) return '' // Prevent duplicate operations // Create new request when share button is clicked @@ -376,7 +340,7 @@ export const CreateRequestLinkView = () => { return (
- router.push('/request')} title="Request" /> + router.push('/home')} title="Request" />
@@ -389,6 +353,8 @@ export const CreateRequestLinkView = () => { onSubmit={handleTokenAmountSubmit} walletBalance={peanutWalletBalance} disabled={!!requestId} + showInfoText + infoText="Leave empty to let payers choose amounts." /> {
) : ( - Share Link + + {!tokenValue || !parseFloat(tokenValue) || parseFloat(tokenValue) === 0 + ? 'Share open request' + : `Share $${tokenValue} request`} + )} {errorState.showError && ( diff --git a/src/components/Request/views/RequestRouter.view.tsx b/src/components/Request/views/RequestRouter.view.tsx deleted file mode 100644 index 020430213..000000000 --- a/src/components/Request/views/RequestRouter.view.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client' -import RouterViewWrapper from '@/components/RouterViewWrapper' -import { useRouter } from 'next/navigation' - -export const RequestRouterView = () => { - const router = useRouter() - - return ( - router.push('/request/create')} - onUserSelect={(username) => router.push(`/request/${username}`)} - /> - ) -} diff --git a/src/components/TransactionDetails/TransactionAvatarBadge.tsx b/src/components/TransactionDetails/TransactionAvatarBadge.tsx index 8fc4a8935..bc18e111e 100644 --- a/src/components/TransactionDetails/TransactionAvatarBadge.tsx +++ b/src/components/TransactionDetails/TransactionAvatarBadge.tsx @@ -19,7 +19,6 @@ interface TransactionAvatarBadgeProps { isLinkTransaction?: boolean transactionType: TransactionType context: 'card' | 'header' | 'drawer' - status?: StatusPillType } /** @@ -33,7 +32,6 @@ const TransactionAvatarBadge: React.FC = ({ size = 'medium', transactionType, context, - status, }) => { let displayIconName: IconName | undefined = undefined let displayInitials: string | undefined = initials @@ -113,8 +111,6 @@ const TransactionAvatarBadge: React.FC = ({ inlineStyle={{ backgroundColor: calculatedBgColor }} textColor={textColor} iconFillColor={iconFillColor} - showStatusPill - statusPillStatus={status} /> ) } diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index 8142f8aef..83654e410 100644 --- a/src/components/TransactionDetails/TransactionCard.tsx +++ b/src/components/TransactionDetails/TransactionCard.tsx @@ -89,8 +89,16 @@ const TransactionCard: React.FC = ({ usdAmount = Number(transaction.currency?.amount ?? amount) } - const formattedAmount = formatCurrency(Math.abs(usdAmount).toString()) - const displayAmount = `${sign}$${formattedAmount}` + const formattedAmount = formatCurrency(Math.abs(usdAmount).toString(), 2, 0) + const formattedTotalAmountCollected = formatCurrency(transaction.totalAmountCollected.toString(), 2, 0) + + let displayAmount = `${sign}$${formattedAmount}` + + if (transaction.isRequestPotLink && Number(transaction.amount) > 0) { + displayAmount = `$${formattedTotalAmountCollected} / $${formattedAmount}` + } else if (transaction.isRequestPotLink && Number(transaction.amount) === 0) { + displayAmount = `$${formattedTotalAmountCollected}` + } let currencyDisplayAmount: string | undefined if (transaction.currency && transaction.currency.code.toUpperCase() !== 'USD') { @@ -105,36 +113,35 @@ const TransactionCard: React.FC = ({
{/* txn avatar component handles icon/initials/colors */} -
- {isPerkReward ? ( - <> - - {status && } - - ) : avatarUrl ? ( -
- Icon - - {status && } -
- ) : ( - */} + {isPerkReward ? ( + <> + + {status && } + + ) : avatarUrl ? ( +
+ Icon - )} -
+ + {status && } +
+ ) : ( + + )}
{/* display formatted name (address or username) */}
@@ -149,9 +156,10 @@ const TransactionCard: React.FC = ({
{/* display the action icon and type text */} -
+
{getActionIcon(type, transaction.direction)} {isPerkReward ? 'Refund' : getActionText(type)} + {status && }
@@ -183,7 +191,7 @@ const TransactionCard: React.FC = ({ // helper functions function getActionIcon(type: TransactionType, direction: TransactionDirection): React.ReactNode { let iconName: IconName | null = null - let iconSize = 8 + let iconSize = 7 switch (type) { case 'send': diff --git a/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx b/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx index 47eb44b75..bfd88be9f 100644 --- a/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx +++ b/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx @@ -10,6 +10,7 @@ import { isAddress as isWalletAddress } from 'viem' import Card from '../Global/Card' import { Icon, type IconName } from '../Global/Icons/Icon' import { VerifiedUserLabel } from '../UserHeader' +import ProgressBar from '../Global/ProgressBar' import { useRouter } from 'next/navigation' import { twMerge } from 'tailwind-merge' @@ -39,6 +40,11 @@ interface TransactionDetailsHeaderCardProps { avatarUrl?: string haveSentMoneyToUser?: boolean isAvatarClickable?: boolean + showProgessBar?: boolean + progress?: number + goal?: number + isRequestPotTransaction?: boolean + isTransactionClosed: boolean } const getTitle = ( @@ -170,6 +176,11 @@ export const TransactionDetailsHeaderCard: React.FC { const router = useRouter() const typeForAvatar = @@ -183,6 +194,8 @@ export const TransactionDetailsHeaderCard: React.FC
@@ -204,31 +217,49 @@ export const TransactionDetailsHeaderCard: React.FC )}
-
+

{icon && } + +
+ {status && } +

{amountDisplay}

+ + {isNoGoalSet &&

No goal set

}
-
{status && }
+ {!isNoGoalSet && showProgessBar && goal !== undefined && progress !== undefined && ( +
+ +
+ )} ) } diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index 1b45685c1..e7053c956 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -7,7 +7,7 @@ * - Large component that could be split into smaller focused components */ -import Card from '@/components/Global/Card' +import Card, { getCardPosition } from '@/components/Global/Card' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' import { TRANSACTIONS } from '@/constants/query.consts' @@ -17,7 +17,14 @@ import { useUserStore } from '@/redux/hooks' import { chargesApi } from '@/services/charges' import useClaimLink from '@/components/Claim/useClaimLink' import { formatAmount, formatDate, getInitialsFromName, isStableCoin, formatCurrency, getAvatarUrl } from '@/utils' -import { formatIban, printableAddress, shortenAddress, shortenStringLong, slugify } from '@/utils/general.utils' +import { + formatIban, + getContributorsFromCharge, + printableAddress, + shortenAddress, + shortenStringLong, + slugify, +} from '@/utils/general.utils' import { cancelOnramp } from '@/app/actions/onramp' import { captureException } from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' @@ -54,6 +61,9 @@ import { import { mantecaApi } from '@/services/manteca' import { getReceiptUrl } from '@/utils/history.utils' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants' +import TransactionCard from './TransactionCard' +import ContributorCard from '../Global/Contributors/ContributorCard' +import { requestsApi } from '@/services/requests' export const TransactionDetailsReceipt = ({ transaction, @@ -187,6 +197,7 @@ export const TransactionDetailsReceipt = ({ !isPublic && transaction.extraDataForDrawer?.originalType === EHistoryEntryType.MANTECA_ONRAMP && transaction.status === 'pending', + closed: !!(transaction.status === 'closed' && transaction.cancelledDate), } }, [transaction, isPendingBankRequest]) @@ -251,6 +262,19 @@ export const TransactionDetailsReceipt = ({ return false }, [transaction, isPendingSentLink, isPendingRequester, isPendingRequestee]) + const isQRPayment = + transaction && + [EHistoryEntryType.MANTECA_QR_PAYMENT, EHistoryEntryType.SIMPLEFI_QR_PAYMENT].includes( + transaction.extraDataForDrawer!.originalType + ) + + const requestPotContributors = useMemo(() => { + if (!transaction || !transaction.requestPotPayments) return [] + return getContributorsFromCharge(transaction.requestPotPayments) + }, [transaction]) + + const formattedTotalAmountCollected = formatCurrency(transaction?.totalAmountCollected?.toString() ?? '0', 2, 0) + useEffect(() => { const getTokenDetails = async () => { if (!transaction) { @@ -313,7 +337,7 @@ export const TransactionDetailsReceipt = ({ // ensure we have a valid number for display const numericAmount = typeof usdAmount === 'bigint' ? Number(usdAmount) : usdAmount const safeAmount = isNaN(numericAmount) || numericAmount === null || numericAmount === undefined ? 0 : numericAmount - const amountDisplay = `$ ${formatCurrency(Math.abs(safeAmount).toString())}` + let amountDisplay = `$${formatCurrency(Math.abs(safeAmount).toString())}` const feeDisplay = transaction.fee !== undefined ? formatAmount(transaction.fee as number) : 'N/A' @@ -337,6 +361,12 @@ export const TransactionDetailsReceipt = ({ } } + if (transaction.isRequestPotLink && Number(transaction.amount) > 0) { + amountDisplay = `$${formatCurrency(transaction.amount.toString())}` + } else if (transaction.isRequestPotLink && Number(transaction.amount) === 0) { + amountDisplay = `$${formattedTotalAmountCollected} collected` + } + // Show profile button only if txn is completed, not to/by a guest user and its a send/request/receive txn const isAvatarClickable = !!transaction && @@ -347,6 +377,27 @@ export const TransactionDetailsReceipt = ({ transaction.extraDataForDrawer?.transactionCardType === 'request' || transaction.extraDataForDrawer?.transactionCardType === 'receive') + const closeRequestLink = async () => { + if (isPendingRequester && setIsLoading && onClose) { + setIsLoading(true) + try { + if (transaction.isRequestPotLink) { + await requestsApi.close(transaction.id) + } else { + await chargesApi.cancel(transaction.id) + } + await queryClient.invalidateQueries({ + queryKey: [TRANSACTIONS], + }) + setIsLoading(false) + onClose() + } catch (error) { + captureException(error) + console.error('Error canceling charge:', error) + setIsLoading(false) + } + } + } // Special rendering for PERK_REWARD type const isPerkReward = transaction.extraDataForDrawer?.originalType === EHistoryEntryType.PERK_REWARD const perkRewardData = transaction.extraDataForDrawer?.perkReward @@ -456,6 +507,11 @@ export const TransactionDetailsReceipt = ({ avatarUrl={avatarUrl ?? getAvatarUrl(transaction)} haveSentMoneyToUser={transaction.haveSentMoneyToUser} isAvatarClickable={isAvatarClickable} + showProgessBar={transaction.isRequestPotLink} + goal={Number(transaction.amount)} + progress={Number(formattedTotalAmountCollected)} + isRequestPotTransaction={transaction.isRequestPotLink} + isTransactionClosed={transaction.status === 'closed'} /> {/* Perk eligibility banner */} @@ -607,6 +663,18 @@ export const TransactionDetailsReceipt = ({ )} + {rowVisibilityConfig.closed && ( + <> + {transaction.cancelledDate && ( + + )} + + )} + {rowVisibilityConfig.claimed && ( <> {transaction.claimedAt && ( @@ -1068,31 +1136,12 @@ export const TransactionDetailsReceipt = ({ iconClassName="p-1" loading={isLoading} disabled={isLoading} - onClick={() => { - setIsLoading(true) - chargesApi - .cancel(transaction.id) - .then(() => { - queryClient - .invalidateQueries({ - queryKey: [TRANSACTIONS], - }) - .then(() => { - setIsLoading(false) - onClose() - }) - }) - .catch((error) => { - captureException(error) - console.error('Error canceling charge:', error) - setIsLoading(false) - }) - }} + onClick={closeRequestLink} variant={'primary-soft'} shadowSize="4" className="flex w-full items-center gap-1" > - Cancel request + {transaction.totalAmountCollected > 0 ? 'Close request' : 'Cancel request'}
)} @@ -1143,9 +1192,23 @@ export const TransactionDetailsReceipt = ({
)} + {isQRPayment && ( + + )} + {shouldShowShareReceipt && !!getReceiptUrl(transaction) && (
- Share Receipt + + Share Receipt +
)} @@ -1360,6 +1423,21 @@ export const TransactionDetailsReceipt = ({ }} /> )} + + {requestPotContributors.length > 0 && ( + <> +

Contributors ({requestPotContributors.length})

+
+ {requestPotContributors.map((contributor, index) => ( + + ))} +
+ + )}
) } diff --git a/src/components/TransactionDetails/transaction-details.utils.ts b/src/components/TransactionDetails/transaction-details.utils.ts index 09462bea5..73fa42274 100644 --- a/src/components/TransactionDetails/transaction-details.utils.ts +++ b/src/components/TransactionDetails/transaction-details.utils.ts @@ -18,6 +18,7 @@ export type TransactionDetailsRowKey = | 'comment' | 'attachment' | 'mantecaDepositInfo' + | 'closed' // order of the rows in the receipt export const transactionDetailsRowKeys: TransactionDetailsRowKey[] = [ @@ -38,6 +39,7 @@ export const transactionDetailsRowKeys: TransactionDetailsRowKey[] = [ 'comment', 'networkFee', 'attachment', + 'closed', ] export const getBankAccountLabel = (type: string) => { diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts index 45d578ae7..3693d7c0b 100644 --- a/src/components/TransactionDetails/transactionTransformer.ts +++ b/src/components/TransactionDetails/transactionTransformer.ts @@ -13,7 +13,7 @@ import { import { type StatusPillType } from '../Global/StatusPill' import type { Address } from 'viem' import { PEANUT_WALLET_CHAIN } from '@/constants' -import { type HistoryEntryPerkReward } from '@/services/services.types' +import { type HistoryEntryPerkReward, type ChargeEntry } from '@/services/services.types' /** * @fileoverview maps raw transaction history data from the api/hook to the format needed by ui components. @@ -130,6 +130,9 @@ export interface TransactionDetails { createdAt?: string | Date completedAt?: string | Date points?: number + isRequestPotLink?: boolean + requestPotPayments?: ChargeEntry[] + totalAmountCollected: number } /** @@ -411,6 +414,10 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact case 'EXPIRED': uiStatus = 'cancelled' break + case 'CLOSED': + // If the total amount collected is 0, the link is treated as cancelled + uiStatus = entry.totalAmountCollected === 0 ? 'cancelled' : 'closed' + break default: { const knownStatuses: StatusType[] = [ @@ -539,6 +546,9 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact createdAt: entry.createdAt, completedAt: entry.completedAt, haveSentMoneyToUser: entry.extraData?.haveSentMoneyToUser as boolean, + isRequestPotLink: entry.isRequestLink, + requestPotPayments: entry.charges, + totalAmountCollected: entry.totalAmountCollected ?? 0, } return { diff --git a/src/components/User/UserCard.tsx b/src/components/User/UserCard.tsx index 8bb913521..e0a96ecb8 100644 --- a/src/components/User/UserCard.tsx +++ b/src/components/User/UserCard.tsx @@ -7,9 +7,11 @@ import Card from '../Global/Card' import { Icon, type IconName } from '../Global/Icons/Icon' import AvatarWithBadge, { type AvatarSize } from '../Profile/AvatarWithBadge' import { VerifiedUserLabel } from '../UserHeader' +import { twMerge } from 'tailwind-merge' +import ProgressBar from '../Global/ProgressBar' interface UserCardProps { - type: 'send' | 'request' | 'received_link' + type: 'send' | 'request' | 'received_link' | 'request_pay' username: string fullName?: string recipientType?: RecipientType @@ -18,6 +20,9 @@ interface UserCardProps { fileUrl?: string isVerified?: boolean haveSentMoneyToUser?: boolean + amount?: number + amountCollected?: number + isRequestPot?: boolean } const UserCard = ({ @@ -30,6 +35,9 @@ const UserCard = ({ fileUrl, isVerified, haveSentMoneyToUser, + amount, + amountCollected, + isRequestPot, }: UserCardProps) => { const getIcon = (): IconName | undefined => { if (type === 'send') return 'arrow-up-right' @@ -43,6 +51,7 @@ const UserCard = ({ if (type === 'send') title = `You're sending money to` if (type === 'request') title = `Requesting money from` if (type === 'received_link') title = `You received` + if (type === 'request_pay') title = `${fullName ?? username} is requesting` return (
@@ -51,36 +60,58 @@ const UserCard = ({ ) }, [type]) + const getAddressLinkTitle = () => { + if (isRequestPot && amount && amount > 0) return `$${amount}` // If goal is set. + if (!amount && isRequestPot) return `Pay what you want` // If no goal is set. + + return username + } + return ( - - -
- {getTitle()} - {recipientType !== 'USERNAME' ? ( - - ) : ( - - )} - + +
+ +
+ {getTitle()} + {recipientType !== 'USERNAME' || type === 'request_pay' ? ( + + ) : ( + + )} + +
+ {amount !== undefined && amountCollected !== undefined && type === 'request_pay' && amount > 0 && ( + = amount} /> + )}
) } diff --git a/src/hooks/usePaymentInitiator.ts b/src/hooks/usePaymentInitiator.ts index e783d56cd..b6e2497e5 100644 --- a/src/hooks/usePaymentInitiator.ts +++ b/src/hooks/usePaymentInitiator.ts @@ -25,6 +25,7 @@ import { waitForTransactionReceipt } from 'wagmi/actions' import { getRoute, type PeanutCrossChainRoute } from '@/services/swap' import { estimateTransactionCostUsd } from '@/app/actions/tokens' import { captureException } from '@sentry/nextjs' +import { useRouter } from 'next/navigation' enum ELoadingStep { IDLE = 'Idle', @@ -57,6 +58,7 @@ export interface InitiatePaymentPayload { isExternalWalletFlow?: boolean transactionType?: TChargeTransactionType attachmentOptions?: IAttachmentOptions + returnAfterChargeCreation?: boolean } interface InitiationResult { @@ -77,6 +79,7 @@ export const usePaymentInitiator = () => { const { switchChainAsync } = useSwitchChain() const { address: wagmiAddress } = useAppKitAccount() const { sendTransactionAsync } = useSendTransaction() + const router = useRouter() const config = useConfig() const { chain: connectedWalletChain } = useWagmiAccount() @@ -158,7 +161,9 @@ export const usePaymentInitiator = () => { if (currentUrl.searchParams.get('chargeId') === activeChargeDetails.uuid) { const newUrl = new URL(window.location.href) newUrl.searchParams.delete('chargeId') - window.history.replaceState(null, '', newUrl.toString()) + // Use router.push (not window.history.replaceState) so that + // the components using the search params will be updated + router.push(newUrl.pathname + newUrl.search) } } return { @@ -168,7 +173,7 @@ export const usePaymentInitiator = () => { success: false, } }, - [activeChargeDetails] + [activeChargeDetails, router] ) // prepare transaction details (called from Confirm view) @@ -391,11 +396,9 @@ export const usePaymentInitiator = () => { const newUrl = new URL(window.location.href) if (payload.requestId) newUrl.searchParams.delete('id') newUrl.searchParams.set('chargeId', chargeDetailsToUse.uuid) - window.history.replaceState( - { ...window.history.state, as: newUrl.href, url: newUrl.href }, - '', - newUrl.href - ) + // Use router.push (not window.history.replaceState) so that + // the components using the search params will be updated + router.push(newUrl.pathname + newUrl.search) console.log('Updated URL with chargeId:', newUrl.href) } } @@ -619,12 +622,13 @@ export const usePaymentInitiator = () => { // 2. handle charge state if ( - chargeCreated && - (payload.isExternalWalletFlow || - !isPeanutWallet || - (isPeanutWallet && - (!areEvmAddressesEqual(determinedChargeDetails.tokenAddress, PEANUT_WALLET_TOKEN) || - determinedChargeDetails.chainId !== PEANUT_WALLET_CHAIN.id.toString()))) + payload.returnAfterChargeCreation || // For request pot payment, return after charge creation + (chargeCreated && + (payload.isExternalWalletFlow || + !isPeanutWallet || + (isPeanutWallet && + (!areEvmAddressesEqual(determinedChargeDetails.tokenAddress, PEANUT_WALLET_TOKEN) || + determinedChargeDetails.chainId !== PEANUT_WALLET_CHAIN.id.toString())))) ) { console.log( `Charge created. Transitioning to Confirm view for: ${ @@ -673,7 +677,9 @@ export const usePaymentInitiator = () => { if (currentUrl.searchParams.get('chargeId') === determinedChargeDetails.uuid) { const newUrl = new URL(window.location.href) newUrl.searchParams.delete('chargeId') - window.history.replaceState(null, '', newUrl.toString()) + // Use router.push (not window.history.replaceState) so that + // the components using the search params will be updated + router.push(newUrl.pathname + newUrl.search) console.log('URL updated, chargeId removed.') } } @@ -691,6 +697,7 @@ export const usePaymentInitiator = () => { handleError, setLoadingStep, setError, + router, setTransactionHash, setPaymentDetails, loadingStep, diff --git a/src/services/requests.ts b/src/services/requests.ts index 6c6089daf..f7932af47 100644 --- a/src/services/requests.ts +++ b/src/services/requests.ts @@ -1,20 +1,21 @@ import { PEANUT_API_URL } from '@/constants' import { type CreateRequestRequest, type TRequestResponse } from './services.types' -import { fetchWithSentry } from '@/utils' +import { fetchWithSentry, jsonStringify } from '@/utils' +import Cookies from 'js-cookie' export const requestsApi = { create: async (data: CreateRequestRequest): Promise => { - const formData = new FormData() - - Object.entries(data).forEach(([key, value]) => { - if (value !== undefined) { - formData.append(key, value) - } - }) - - const response = await fetchWithSentry('/api/proxy/withFormData/requests', { + const token = Cookies.get('jwt-token') + if (!token) { + throw new Error('Authentication token not found. Please log in again.') + } + const response = await fetchWithSentry(`${PEANUT_API_URL}/requests`, { method: 'POST', - body: formData, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: jsonStringify(data), }) if (!response.ok) { @@ -37,17 +38,17 @@ export const requestsApi = { }, update: async (id: string, data: Partial): Promise => { - const formData = new FormData() - - Object.entries(data).forEach(([key, value]) => { - if (value !== undefined) { - formData.append(key, value) - } - }) - - const response = await fetchWithSentry(`/api/proxy/withFormData/requests/${id}`, { + const token = Cookies.get('jwt-token') + if (!token) { + throw new Error('Authentication token not found. Please log in again.') + } + const response = await fetchWithSentry(`${PEANUT_API_URL}/requests/${id}`, { method: 'PATCH', - body: formData, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: jsonStringify(data), }) if (!response.ok) { @@ -85,4 +86,21 @@ export const requestsApi = { } return response.json() }, + + close: async (uuid: string): Promise => { + const token = Cookies.get('jwt-token') + if (!token) { + throw new Error('Authentication token not found. Please log in again.') + } + const response = await fetchWithSentry(`${PEANUT_API_URL}/requests/${uuid}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + if (!response.ok) { + throw new Error(`Failed to close request: ${response.statusText}`) + } + return response.json() + }, } diff --git a/src/services/services.types.ts b/src/services/services.types.ts index a3c39e1a7..7313559b3 100644 --- a/src/services/services.types.ts +++ b/src/services/services.types.ts @@ -19,9 +19,6 @@ export interface CreateRequestRequest { tokenAddress: string tokenDecimals: string tokenSymbol: string - attachment?: File - mimeType?: string - filename?: string } export interface TRequestResponse { @@ -48,6 +45,7 @@ export interface TRequestResponse { username: string } } + totalCollectedAmount: number } export interface ChargeEntry { diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 6065f2433..d488f726b 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -10,6 +10,7 @@ import { getAddress, isAddress, erc20Abi } from 'viem' import * as wagmiChains from 'wagmi/chains' import { getPublicClient, type ChainId } from '@/app/actions/clients' import { NATIVE_TOKEN_ADDRESS, SQUID_ETH_ADDRESS } from './token.utils' +import { type ChargeEntry } from '@/services/services.types' export function urlBase64ToUint8Array(base64String: string) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4) @@ -377,8 +378,8 @@ export const formatNumberForDisplay = ( }) } -export function formatCurrency(valueStr: string | undefined): string { - return formatNumberForDisplay(valueStr, { maxDecimals: 2, minDecimals: 2 }) +export function formatCurrency(valueStr: string | undefined, maxDecimals: number = 2, minDecimals: number = 2): string { + return formatNumberForDisplay(valueStr, { maxDecimals, minDecimals }) } /** @@ -1357,3 +1358,22 @@ export const getValidRedirectUrl = (redirectUrl: string, fallbackRoute: string) return fallbackRoute } } + +export const getContributorsFromCharge = (charges: ChargeEntry[]) => { + return charges.map((charge) => { + const successfulPayment = charge.payments.at(-1) + let username = successfulPayment?.payerAccount?.user?.username + if (successfulPayment?.payerAccount?.type === 'evm-address') { + username = successfulPayment.payerAccount.identifier + } + + return { + uuid: charge.uuid, + payments: charge.payments, + amount: charge.tokenAmount, + username, + fulfillmentPayment: charge.fulfillmentPayment, + isUserVerified: successfulPayment?.payerAccount?.user?.bridgeKycStatus === 'approved', + } + }) +} diff --git a/src/utils/history.utils.ts b/src/utils/history.utils.ts index 29adf76c9..8ee636cb4 100644 --- a/src/utils/history.utils.ts +++ b/src/utils/history.utils.ts @@ -6,6 +6,7 @@ import { formatUnits } from 'viem' import { type Hash } from 'viem' import { getTokenDetails } from '@/utils' import { getCurrencyPrice } from '@/app/actions/currency' +import { type ChargeEntry } from '@/services/services.types' export enum EHistoryEntryType { REQUEST = 'REQUEST', @@ -71,6 +72,7 @@ export enum EHistoryStatus { refunded = 'refunded', canceled = 'canceled', // from simplefi, canceled with only one l expired = 'expired', + CLOSED = 'CLOSED', } export const FINAL_STATES: HistoryStatus[] = [ @@ -81,6 +83,7 @@ export const FINAL_STATES: HistoryStatus[] = [ EHistoryStatus.REFUNDED, EHistoryStatus.CANCELED, EHistoryStatus.ERROR, + EHistoryStatus.CLOSED, ] export type HistoryEntryType = `${EHistoryEntryType}` @@ -129,6 +132,9 @@ export type HistoryEntry = { completedAt?: string | Date isVerified?: boolean points?: number + isRequestLink?: boolean // true if the transaction is a request pot link + charges?: ChargeEntry[] + totalAmountCollected?: number } export function isFinalState(transaction: Pick): boolean { @@ -219,7 +225,14 @@ export async function completeHistoryEntry(entry: HistoryEntry): Promise