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
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ EXPO_PUBLIC_RAIN_CARD_DEPOSIT_TOKEN_SYMBOL=rUSD
EXPO_PUBLIC_PERSONA_RAIN_TEMPLATE_ID=
EXPO_PUBLIC_PERSONA_SANDBOX_ENVIRONMENT_ID=

# --- Card spend v2: the Credit and Smart funding modes ---
# Off means the card behaves exactly as it does today: every cardholder is on the v1
# module spending cash, and the two modes they cannot use are not offered at all —
# the spend-mode row is not rendered rather than shown inert.
#
# Gated on this flag rather than on whether the addresses are set, so a QA build can
# point at a testnet deployment before mainnet has one. BOTH the flag and the
# addresses are required: a flag with no address is a misconfigured build, an address
# with no flag is a deliberate dark launch, and they fail differently.
EXPO_PUBLIC_CARD_SPEND_V2=true
# SolidCashModuleV2 on Fuse. One address for both halves — the core delegatecalls
# anything it does not implement into the setters, so reads use it too.
EXPO_PUBLIC_CASH_MODULE_V2_ADDRESS=
# SolidSpendLens on Fuse — the cohort-aware read serving both module generations.
EXPO_PUBLIC_SPEND_LENS_V2_ADDRESS=

# Quote the launch cashback rates baked into the app (Core 3%, Prime 4%, Ultra 5%)
# instead of the rate the rewards API reports. On unless set to "false" — flip it
# once the admin-configured rates are live.
Expand Down
84 changes: 83 additions & 1 deletion app/(protected)/activity/[clientTxId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
import { cn, eclipseAddress, formatNumber, toTitleCase, withRefreshToken } from '@/lib/utils';
import { cardDeclineReason } from '@/lib/utils/cardDeclineReason';
import {
cardRefundExplorerUrl,
cardSweepExplorerUrl,
cardTransactionExplorerUrl,
formatCardAmount,
Expand Down Expand Up @@ -309,6 +310,9 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
const spend = transaction.spend_details;
const sweepHash = spend?.sweep_tx_hash;
const sweepUrl = cardSweepExplorerUrl(spend);
// Money coming back, and what was taken out of it on the way. See `CardRefundDetails`.
const refund = spend?.refund;
const refundUrl = cardRefundExplorerUrl(refund);
const isApproved = transaction.status === 'approved';
const isDeclined = transaction.status === 'declined';
const isReversed = transaction.status === 'reversed';
Expand Down Expand Up @@ -338,9 +342,16 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
// are different findings that our cardholder copy deliberately softens.
spend.decline_reason && `Decline code: ${spend.decline_reason}`,
sweepHash && `Sweep: ${sweepHash}`,
// The refund leg, because "why is my refund short" is the question this
// screen's refund rows exist to pre-empt — and the one people still write
// in about. Support needs the gross and the deduction, not just the net.
refund && `Refund: ${refund.status} ${refund.paid_usd} of ${refund.gross_usd} USD`,
refund?.cashback_deducted_usd &&
`Cashback withheld from refund: ${refund.cashback_deducted_usd} USD`,
refund?.tx_hash && `Refund tx: ${refund.tx_hash}`,
].filter(Boolean);
return lines.length ? `\n${lines.join('\n')}` : '';
}, [spend, sweepHash, transaction.usd_amount]);
}, [spend, sweepHash, refund, transaction.usd_amount]);

const transactionContext = useMemo(
() =>
Expand All @@ -365,6 +376,10 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
if (sweepUrl) Linking.openURL(sweepUrl);
}, [sweepUrl]);

const handleRefundPress = useCallback(() => {
if (refundUrl) Linking.openURL(refundUrl);
}, [refundUrl]);

const handleLocationPress = useCallback(() => {
if (!merchantPlace) return;
Linking.openURL(getCardMerchantMapsUrl(merchantPlace, transaction.merchant_name));
Expand Down Expand Up @@ -546,6 +561,70 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
</Value>
),
},
// The three figures that make a short refund explainable.
//
// Only shown when something was actually withheld. A refund that paid in
// full needs no arithmetic — the amount above already is the amount — and
// printing "Cashback returned: $0.00" on it would invent a concern the
// cardholder did not have.
refund?.cashback_deducted_usd
? {
key: 'refund-cashback',
// Not green. Every other cashback figure on this screen is money
// coming to the user; this one is going back, and colouring it like
// a gain would misread at a glance — which on a figure that makes a
// refund smaller is the one thing worth getting right.
label: (
<View className="flex-row items-center gap-1.5">
<CashbackDiamondIcon size={14} />
<Text className={cn(ROW_TEXT, 'font-medium text-white/70')}>Cashback returned</Text>
</View>
),
value: (
<Value>
-{formatCardAmount(String(refund.cashback_deducted_usd), cardProvider, 'USD')}
</Value>
),
caption: (
<Text className="mt-2 text-[13px] leading-4 text-white/50">
{refund.note ?? 'The cashback this purchase earned was returned with the refund'}
</Text>
),
}
: null,
refund?.cashback_deducted_usd
? {
key: 'refund-paid',
label: (
<Label>{refund.status === 'paid' ? 'Refunded to you' : 'Refund on its way'}</Label>
),
value: (
<Value className="text-brand">
{formatCardAmount(String(refund.paid_usd), cardProvider, 'USD')}
</Value>
),
}
: null,
// The transfer that paid it. Its own row rather than folded into "Sweep":
// that hash is money we took to cover the purchase, this is money we sent
// back, and one label over both is how a cardholder ends up reading a
// refund as another charge.
refundUrl && refund?.tx_hash
? {
key: 'refund-tx',
label: <Label>Refund</Label>,
value: (
<Pressable onPress={handleRefundPress} className="hover:opacity-70">
<View className="flex-row items-center gap-1">
<Underline textClassName={ROW_VALUE_TEXT} borderColor="rgba(255, 255, 255, 1)">
{eclipseAddress(refund.tx_hash)}
</Underline>
<ArrowUpRight color="white" size={16} />
</View>
</Pressable>
),
}
: null,
// What the merchant actually charged, in their own currency — the figure
// the user will recognise from the till, against the dollars they were
// billed at the top of this screen.
Expand Down Expand Up @@ -623,6 +702,9 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
transaction.currency,
declineReason,
transaction.refunded_amount,
refund,
refundUrl,
handleRefundPress,
]);

return (
Expand Down
58 changes: 55 additions & 3 deletions components/Card/NewCardDetails/CardDetailsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ import CardLinksList from '@/components/Card/NewCardDetails/CardLinksList';
import CardRevealSection from '@/components/Card/NewCardDetails/CardRevealSection';
import { EASE_OUT_QUINT, HERO_ENTER, HeroEnter } from '@/components/Card/NewCardDetails/heroMotion';
import ManageCardSheet from '@/components/Card/NewCardDetails/ManageCardSheet';
import SpendingModeCard from '@/components/Card/NewCardDetails/SpendingModeCard';
import BorrowPositionCard from '@/components/Card/NewCardDetails/SpendMode/BorrowPositionCard';
import BorrowPositionSheet from '@/components/Card/NewCardDetails/SpendMode/BorrowPositionSheet';
import SpendModeSheet from '@/components/Card/NewCardDetails/SpendMode/SpendModeSheet';
import useSpendModeFigures from '@/components/Card/NewCardDetails/SpendMode/useSpendModeFigures';
import { useCardPaneVisibility } from '@/components/Card/NewCardDetails/useCardPaneVisibility';
import { usePageLeft } from '@/components/Navbar/Sidebar';
import CashbackDetailsSheet from '@/components/Rewards/NewRewards/CashbackDetailsSheet';
Expand Down Expand Up @@ -107,6 +112,12 @@ const CardDetailsPane = () => {
const [spendSheetSource, setSpendSheetSource] = useState<CardSpendRegistrationSource | null>(
null,
);
// Which funds the card draws on — cash, credit or both. UI only for now: the
// sheet previews the three modes and never commits one.
const spendModeFigures = useSpendModeFigures();
const [isSpendModeOpen, setIsSpendModeOpen] = useState(false);
// The borrow position's own sheet, opened by tapping the card that shows it.
const [isBorrowPositionOpen, setIsBorrowPositionOpen] = useState(false);
// Stable identities: the reveal section folds its opener into the memoised toggle
// handler, which would be rebuilt on every render of this pane otherwise.
const openSpendSheet = useCallback(() => setSpendSheetSource('spending_sheet'), []);
Expand Down Expand Up @@ -146,6 +157,8 @@ const CardDetailsPane = () => {
// rather than merely hiding it also stops the sheet reappearing on the next visit.
setSpendSheetSource(null);
setIsAddToWalletOpen(false);
setIsSpendModeOpen(false);
setIsBorrowPositionOpen(false);
}, [isOpen]);

const isCardFrozen = cardDetails?.status === CardStatus.FROZEN;
Expand Down Expand Up @@ -259,6 +272,34 @@ const CardDetailsPane = () => {
canAddFunds={canAddFundsToCard(fundsAccess)}
/>
</HeroEnter>
{/* Hidden outright rather than shown inert while the card has only one way to be
funded. Until this build can reach the v2 module there is nothing to change to,
and a "Spend mode: Cash [Change]" row that cannot change anything is worse than
no row — it is the one surface that would give away a migration the cardholder
is deliberately never asked about. */}
{spendModeFigures.canChangeMode ? (
<HeroEnter spec={HERO_ENTER.spendMode} style={styles.spendModeCard}>
<SpendingModeCard
mode={spendModeFigures.mode}
onChangeMode={() => setIsSpendModeOpen(true)}
/>
</HeroEnter>
) : null}
{/* Shown to anyone who has a credit line, not only to someone already in debt —
see `showsBorrowPosition`. A cardholder on Credit needs to see what they can
spend against BEFORE they spend it; gating on the loan meant the first thing
they learned about their own line was a decline. */}
{spendModeFigures.showsBorrowPosition ? (
<HeroEnter spec={HERO_ENTER.borrowPosition} style={styles.borrowPositionCard}>
<BorrowPositionCard
borrowed={spendModeFigures.borrowed}
creditLimit={spendModeFigures.creditLimit}
borrowApy={spendModeFigures.borrowApy}
borrowedProgress={spendModeFigures.borrowedProgress}
onPress={() => setIsBorrowPositionOpen(true)}
/>
</HeroEnter>
) : null}
<HeroEnter spec={HERO_ENTER.cashback} style={styles.cashbackCard}>
<CashbackDetailsSheet
trigger={<CardCashbackCard />}
Expand Down Expand Up @@ -308,6 +349,15 @@ const CardDetailsPane = () => {
}}
canWithdraw={canWithdrawFromCard(fundsAccess)}
/>
<SpendModeSheet
isOpen={isOpen && isSpendModeOpen}
onOpenChange={setIsSpendModeOpen}
activeMode={spendModeFigures.mode}
/>
<BorrowPositionSheet
isOpen={isOpen && isBorrowPositionOpen}
onOpenChange={setIsBorrowPositionOpen}
/>
<AddToWalletModal
trigger={null}
isOpen={isAddToWalletOpen || (isOpen && walletGuide !== null)}
Expand Down Expand Up @@ -343,10 +393,12 @@ const styles = StyleSheet.create({
top: 0,
zIndex: 10,
},
// Figma vertical rhythm: 51 from the panel to the action icons, 45 to the cashback
// card, 20 to the links list.
// Figma vertical rhythm (26134:23654): 51 from the panel to the action icons, 53
// to the spend-mode row, then 20 between each card down the stack.
actionsRow: { marginTop: 51 },
cashbackCard: { marginTop: 45 },
spendModeCard: { marginTop: 53 },
borrowPositionCard: { marginTop: 20 },
cashbackCard: { marginTop: 20 },
linksList: { marginTop: 20 },
});

Expand Down
39 changes: 39 additions & 0 deletions components/Card/NewCardDetails/SpendMode/BorrowPositionCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Pressable, StyleSheet } from 'react-native';

import {
BorrowedSummary,
type BorrowedSummaryFigures,
} from '@/components/Card/NewCardDetails/SpendMode/SpendModePanels';

interface BorrowPositionCardProps extends BorrowedSummaryFigures {
onPress: () => void;
}

/**
* The borrow position on the card screen (Figma 26134:23800): the same borrowed
* block the spend-mode sheet shows, on the page's own card background and
* pressable — it opens the position sheet, where the loan can be repaid.
*
* Rendering it at all is the caller's decision: the card screen gates on
* `showsBorrowPosition`, which is a credit line worth naming rather than a loan
* already drawn — an undrawn line still answers "what can I spend on this card",
* which is the question someone on Credit opens the screen with.
*/
const BorrowPositionCard = ({ onPress, ...figures }: BorrowPositionCardProps) => (
<Pressable
accessibilityLabel="View borrow position"
accessibilityRole="button"
className="overflow-hidden rounded-[23px] bg-card transition-all active:scale-[0.98] active:opacity-80"
onPress={onPress}
style={styles.card}
>
<BorrowedSummary {...figures} />
</Pressable>
);

const styles = StyleSheet.create({
// Figma 385 × 123 — the block starts 3pt lower here than it does inside a sheet.
card: { height: 123, paddingTop: 26 },
});

export default BorrowPositionCard;
35 changes: 35 additions & 0 deletions components/Card/NewCardDetails/SpendMode/BorrowPositionSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useCallback } from 'react';

import BorrowPositionSheetContent, {
BORROW_POSITION_SHEET_BOTTOM,
BORROW_POSITION_SHEET_TOP,
} from '@/components/Card/NewCardDetails/SpendMode/BorrowPositionSheetContent';
import CardBottomSheet from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet';
import useSpendModeFigures from '@/components/Card/NewCardDetails/SpendMode/useSpendModeFigures';

interface BorrowPositionSheetProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}

/** The borrow position, opened by tapping the card on the card screen. */
const BorrowPositionSheet = ({ isOpen, onOpenChange }: BorrowPositionSheetProps) => {
const figures = useSpendModeFigures();
const dismiss = useCallback(() => onOpenChange(false), [onOpenChange]);

return (
<CardBottomSheet
isOpen={isOpen}
onOpenChange={onOpenChange}
contentKey="borrow-position"
designTop={BORROW_POSITION_SHEET_TOP}
designBottom={BORROW_POSITION_SHEET_BOTTOM}
>
{({ topPadding }) => (
<BorrowPositionSheetContent figures={figures} onDismiss={dismiss} topPadding={topPadding} />
)}
</CardBottomSheet>
);
};

export default BorrowPositionSheet;
Loading
Loading