Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/components/TransactionDetails/TransactionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import TransactionAvatarBadge from '@/components/TransactionDetails/TransactionA
import { getBankAccountCountryCode } from '@/constants/countryCurrencyMapping'
import { type TransactionDirection } from '@/components/TransactionDetails/TransactionDetailsHeaderCard'
import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer'
import { isCardPaymentEntry } from '@/components/TransactionDetails/transaction-predicates'
import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer'
import {
formatNumberForDisplay,
Expand Down Expand Up @@ -128,10 +129,21 @@ const TransactionCard: React.FC<TransactionCardProps> = ({
currencyDisplayAmount = `≈ ${transaction.currency.code.toUpperCase()} ${formattedCurrencyAmount}`
}

// Spec §4.4: declined card transactions stay in the feed but are visually
// de-emphasized so they don't compete with successful items. Scope to
// declined SPENDS specifically — refunds also populate cardPayment, but
// a failed refund (e.g. processing error) shouldn't be greyed out.
const isDeclinedCardSpend =
status === 'failed' && isCardPaymentEntry(transaction) && !transaction.extraDataForDrawer?.cardPayment?.isRefund

return (
<>
{/* the clickable card */}
<Card position={position} onClick={handleClick} className="cursor-pointer">
<Card
position={position}
onClick={handleClick}
className={twMerge('cursor-pointer', isDeclinedCardSpend && 'opacity-60')}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* txn avatar component handles icon/initials/colors */}
Expand Down
810 changes: 49 additions & 761 deletions src/components/TransactionDetails/TransactionDetailsReceipt.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
'use client'

import { Button } from '@/components/0_Bruddle/Button'
import { Icon } from '@/components/Global/Icons/Icon'
import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer'
import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHistory'
import { TRANSACTIONS } from '@/constants/query.consts'
import { cancelOnramp } from '@/app/actions/onramp'
import { chargesApi } from '@/services/charges'
import { mantecaApi } from '@/services/manteca'
import { captureException } from '@sentry/nextjs'
import { useQueryClient } from '@tanstack/react-query'

/**
* Cancel-deposit buttons for pending bank-deposit-shaped flows.
*
* Replaces three near-identical inline buttons in the receipt:
* - Bridge onramp pending → cancelOnramp(transaction.id)
* - Manteca onramp pending → mantecaApi.cancelDeposit(transaction.id)
* - REQUEST pending + bridge fulfillment + sender role → cancelOnramp(bridgeTransferId) + chargesApi.cancel(transaction.id)
*
* Renders at most one button — conditions are mutually exclusive by
* construction (different originalType / direction / role combos).
*/
export function CancelDepositActions({
transaction,
isPendingBankRequest,
isLoading,
setIsLoading,
onClose,
}: {
transaction: TransactionDetails
isPendingBankRequest: boolean
isLoading: boolean | undefined
setIsLoading: ((loading: boolean) => void) | undefined
onClose: (() => void) | undefined
}) {
const queryClient = useQueryClient()
if (!setIsLoading || !onClose) return null

const refetchAndClose = () =>
queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }).then(() => {
setIsLoading(false)
onClose()
})

const wrapAction = async (run: () => Promise<void>) => {
setIsLoading(true)
try {
await run()
await refetchAndClose()
} catch (error) {
captureException(error)
console.error('Error canceling deposit:', error)
setIsLoading(false)
}
}

// 1. Bridge onramp pending — generic bank deposit cancel.
const showBridgeOnrampCancel =
transaction.direction === 'bank_deposit' &&
transaction.extraDataForDrawer?.originalType !== EHistoryEntryType.REQUEST &&
transaction.status === 'pending' &&
!!transaction.extraDataForDrawer?.depositInstructions

if (showBridgeOnrampCancel) {
return (
<CancelButton
disabled={!!isLoading}
onClick={() =>
wrapAction(async () => {
const result = await cancelOnramp(transaction.id)
if (result.error) throw new Error(result.error)
})
}
/>
)
}

// 2. Manteca onramp pending.
const showMantecaCancel =
transaction.extraDataForDrawer?.originalType === EHistoryEntryType.MANTECA_ONRAMP &&
transaction.status === 'pending'

if (showMantecaCancel) {
return (
<CancelButton
disabled={!!isLoading}
onClick={() =>
wrapAction(async () => {
const result = await mantecaApi.cancelDeposit(transaction.id)
if (result.error) throw new Error(result.error)
})
}
/>
)
}

// 3. REQUEST pending + bridge fulfillment + sender role — cancels the
// bridge-side onramp first, then the charge so the recipient stops seeing
// the request as outstanding.
const showPendingBankRequestCancel =
isPendingBankRequest && transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER

if (showPendingBankRequestCancel) {
return (
<div className="pr-1">
<CancelButton
label="Cancel Request"
disabled={!!isLoading}
onClick={() =>
wrapAction(async () => {
const bridgeTransferId = transaction.extraDataForDrawer?.bridgeTransferId
if (!bridgeTransferId) {
throw new Error('Cannot cancel REQUEST: missing bridgeTransferId on transaction')
}
// Bridge cancel must succeed before we cancel the
// charge — otherwise the onramp orphans on Bridge's
// side while the user sees the request as cancelled.
const bridgeResult = await cancelOnramp(bridgeTransferId)
if (bridgeResult.error) throw new Error(bridgeResult.error)
await chargesApi.cancel(transaction.id)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
/>
</div>
)
}

return null
}

function CancelButton({
label = 'Cancel deposit',
disabled,
onClick,
}: {
label?: string
disabled: boolean
onClick: () => void
}) {
return (
<Button
disabled={disabled}
onClick={onClick}
variant={'primary-soft'}
className="flex w-full items-center gap-1"
shadowSize="4"
>
<div className="flex items-center">
<Icon name="cancel" className="mr-0.5 min-w-3 rounded-full border border-black p-0.5" />
</div>
<span>{label}</span>
</Button>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
'use client'

import { type RefObject } from 'react'
import { twMerge } from 'tailwind-merge'
import Card from '@/components/Global/Card'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { Icon } from '@/components/Global/Icons/Icon'
import { PerkIcon } from '@/components/TransactionDetails/PerkIcon'
import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer'
import { type HistoryEntryPerkReward } from '@/services/services.types'
import { formatDate } from '@/utils/general.utils'
import { useModalsContext } from '@/context/ModalsContext'

/**
* Self-contained receipt for PERK_REWARD entries. Replaces the early-return
* branch in TransactionDetailsReceipt — Perk has its own header (PerkIcon +
* "Peanut Reward" copy), its own status pills, and a tiny detail card with
* date + reason. None of it composes with the generic transaction details
* card, hence a separate top-level layout instead of slotting into rows.
*/
export function PerkRewardReceipt({
transaction,
perkRewardData,
amountDisplay,
contentRef,
className,
}: {
transaction: TransactionDetails
perkRewardData: HistoryEntryPerkReward
amountDisplay: string
contentRef?: RefObject<HTMLDivElement>
className?: string
}) {
const { setIsSupportModalOpen } = useModalsContext()

return (
<div ref={contentRef} className={twMerge('space-y-4', className)}>
{/* Perk Reward Header — top section with logo, amount, and status */}
<Card position="single" className="px-4 py-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<PerkIcon size="medium" />
<div className="flex flex-col">
<h2 className="text-lg font-semibold text-gray-900">Peanut Reward</h2>
<p className="text-2xl font-bold text-gray-900">{amountDisplay}</p>
</div>
</div>
<div className="flex-shrink-0">
{transaction.status === 'completed' ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-700">
Completed
</span>
) : transaction.status === 'pending' || transaction.status === 'processing' ? (
<span className="rounded-full bg-yellow-100 px-3 py-1 text-xs font-medium text-yellow-700">
Processing
</span>
) : (
<span className="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700">
{transaction.status}
</span>
)}
</div>
</div>
<p className="mt-3 text-sm text-gray-600">Earn rewards every time your friends use Peanut.</p>
</Card>

{/* Perk details — date + reason. Reason has a payment-UUID suffix
stripped because PerkUsage uses it for idempotency (purchase-
listener.ts) and shouldn't surface to users. Backend follow-up:
add requestPaymentUuid column so reason can be clean. */}
<Card position="single" className="px-4 py-0">
<PaymentInfoRow
label="Received"
value={formatDate(new Date(transaction.date))}
hideBottomBorder={false}
/>
<PaymentInfoRow
label="Reason"
value={perkRewardData.reason.replace(/\s*\(payment:\s*[a-f0-9-]+\)/i, '')}
hideBottomBorder={true}
/>
</Card>

<button
onClick={() => setIsSupportModalOpen(true)}
className="flex w-full items-center justify-center gap-2 text-sm font-medium text-grey-1 underline transition-colors hover:text-black"
>
<Icon name="peanut-support" size={16} className="text-grey-1" />
Issues with this transaction?
</button>
</div>
)
}
Loading
Loading