From 9d1bcb5a9d25ce252e4fdb7fd3fa266878dadab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josip=20Boj=C4=8Di=C4=87?= Date: Mon, 12 Jan 2026 12:41:34 +0100 Subject: [PATCH 1/3] Refactor to use view transitions for card animations --- app/features/accounts/account-hooks.ts | 23 +- app/features/accounts/account-repository.ts | 4 +- app/features/gift-cards/add-gift-card.tsx | 25 +- .../gift-cards/card-stack-animation.ts | 211 ----------- .../gift-cards/card-stack-constants.ts | 6 + .../gift-cards/card-stack.constants.ts | 22 -- app/features/gift-cards/card-stack.tsx | 121 ------- ...er-section.tsx => discover-gift-cards.tsx} | 16 +- app/features/gift-cards/empty-state.tsx | 8 +- app/features/gift-cards/expanded-view.tsx | 153 -------- app/features/gift-cards/gift-card-details.tsx | 171 +++++++++ app/features/gift-cards/gift-card-item.tsx | 14 +- app/features/gift-cards/gift-cards.tsx | 102 ++++++ app/features/gift-cards/stack-view.tsx | 48 --- app/features/gift-cards/stacked-cards.tsx | 63 ---- app/features/gift-cards/transitions.css | 78 +++++ app/features/gift-cards/use-card-stack.ts | 331 ------------------ app/features/gift-cards/use-discover-cards.ts | 23 +- app/routes/_protected.gift-cards.$cardId.tsx | 6 + app/routes/_protected.gift-cards._index.tsx | 12 +- app/routes/_protected.gift-cards.tsx | 1 + ...ted.gift-cards_.add.$mintUrl.$currency.tsx | 30 +- 22 files changed, 448 insertions(+), 1020 deletions(-) delete mode 100644 app/features/gift-cards/card-stack-animation.ts create mode 100644 app/features/gift-cards/card-stack-constants.ts delete mode 100644 app/features/gift-cards/card-stack.constants.ts delete mode 100644 app/features/gift-cards/card-stack.tsx rename app/features/gift-cards/{discover-section.tsx => discover-gift-cards.tsx} (70%) delete mode 100644 app/features/gift-cards/expanded-view.tsx create mode 100644 app/features/gift-cards/gift-card-details.tsx create mode 100644 app/features/gift-cards/gift-cards.tsx delete mode 100644 app/features/gift-cards/stack-view.tsx delete mode 100644 app/features/gift-cards/stacked-cards.tsx create mode 100644 app/features/gift-cards/transitions.css delete mode 100644 app/features/gift-cards/use-card-stack.ts create mode 100644 app/routes/_protected.gift-cards.$cardId.tsx diff --git a/app/features/accounts/account-hooks.ts b/app/features/accounts/account-hooks.ts index 65552dff8..eecc8f477 100644 --- a/app/features/accounts/account-hooks.ts +++ b/app/features/accounts/account-hooks.ts @@ -166,8 +166,20 @@ export function useAccounts(select?: { (data: Account[]) => { const extendedData = AccountService.getExtendedAccounts(user, data); - if (!currency && !type && isOnline === undefined) { - return extendedData as ExtendedAccount[]; + if ( + !currency && + !type && + isOnline === undefined && + !excludeClosedLoopAccounts && + !onlyIncludeClosedLoopAccounts + ) { + return extendedData + .slice() + .sort( + (a, b) => + new Date(a.createdAt).getTime() - + new Date(b.createdAt).getTime(), + ) as ExtendedAccount[]; } const filteredData = extendedData.filter( @@ -193,7 +205,12 @@ export function useAccounts(select?: { }, ); - return filteredData; + return filteredData + .slice() + .sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); }, [ currency, diff --git a/app/features/accounts/account-repository.ts b/app/features/accounts/account-repository.ts index 29e3df917..8d91088bf 100644 --- a/app/features/accounts/account-repository.ts +++ b/app/features/accounts/account-repository.ts @@ -55,7 +55,7 @@ export class AccountRepository { */ async get(id: string, options?: Options): Promise { // Currently we limit the number of proofs returned to 6000 - // We will need to handle that somehow later (e.g. require use to swap when the limit is reaching) + // We will need to handle that somehow later (e.g. require user to swap when the limit is reaching) const query = this.db .from('accounts') .select('*, cashu_proofs(*)') @@ -82,7 +82,7 @@ export class AccountRepository { */ async getAll(userId: string, options?: Options): Promise { // Currently we limit the number of proofs returned to 6000 - // We will need to handle that somehow later (e.g. require use to swap when the limit is reaching) + // We will need to handle that somehow later (e.g. require user to swap when the limit is reaching) const query = this.db .from('accounts') .select('*, cashu_proofs(*)') diff --git a/app/features/gift-cards/add-gift-card.tsx b/app/features/gift-cards/add-gift-card.tsx index a83b6d747..a2690db15 100644 --- a/app/features/gift-cards/add-gift-card.tsx +++ b/app/features/gift-cards/add-gift-card.tsx @@ -10,42 +10,31 @@ import { import { Button } from '~/components/ui/button'; import { WalletCard, WalletCardBackground } from '~/components/wallet-card'; import { useAddCashuAccount } from '~/features/accounts/account-hooks'; -import { NotFoundError } from '~/features/shared/error'; import { useToast } from '~/hooks/use-toast'; -import type { Currency } from '~/lib/money/types'; import { LinkWithViewTransition } from '~/lib/transitions'; -import { DISCOVER_MINTS } from './use-discover-cards'; +import type { GiftCardInfo } from './use-discover-cards'; type AddGiftCardProps = { - mintUrl: string; - currency: Currency; + giftCard: GiftCardInfo; }; /** * Add Gift Card component - displays the full size gift card image * and an "Add Card" button to add the discover card to the user's wallet. */ -export function AddGiftCard({ mintUrl, currency }: AddGiftCardProps) { +export function AddGiftCard({ giftCard }: AddGiftCardProps) { const [isAdding, setIsAdding] = useState(false); const addAccount = useAddCashuAccount(); const navigate = useNavigate(); const { toast } = useToast(); - const mint = DISCOVER_MINTS.find( - (m) => m.url === mintUrl && m.currency === currency, - ); - - if (!mint) { - throw new NotFoundError('Card not found'); - } - const handleAddCard = async () => { setIsAdding(true); try { await addAccount({ - name: mint.name, - currency: mint.currency, - mintUrl: mint.url, + name: giftCard.name, + currency: giftCard.currency, + mintUrl: giftCard.url, type: 'cashu', }); toast({ @@ -85,7 +74,7 @@ export function AddGiftCard({ mintUrl, currency }: AddGiftCardProps) { className="absolute inset-0 mx-auto flex max-w-sm items-center justify-center px-4" > - + diff --git a/app/features/gift-cards/card-stack-animation.ts b/app/features/gift-cards/card-stack-animation.ts deleted file mode 100644 index 0c07980d2..000000000 --- a/app/features/gift-cards/card-stack-animation.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { CSSProperties } from 'react'; - -import { - ANIMATION_DURATION_MS, - BOUNCE_TIMING, - CARD_HEIGHT, - CASCADE_DELAY_PER_CARD_MS, - COLLAPSED_OFFSET, - CONTENT_TOP, -} from './card-stack.constants'; - -export type CardPosition = { - top: number; - left: number; - width: number; -}; - -export type CapturedCardPosition = CardPosition & { - index: number; -}; - -/** - * Calculate Y position of a card in the collapsed stack relative to selected card - */ -export const getCollapsedY = ( - cardIndex: number, - selectedIndex: number, - baseTop: number, -): number => baseTop + (cardIndex - selectedIndex) * COLLAPSED_OFFSET; - -/** - * Create CSS transform string from position values - */ -export const toTransform = (left: number, top: number): string => - `translate(${left}px, ${top}px)`; - -/** - * Create initial card styles - all cards at collapsed positions with no transition. - * Used as the starting state before entering animation begins. - */ -export function createInitialCardStyles( - selectedIndex: number, - cardCount: number, - baseTop: number, - baseLeft: number, - cardWidth: number, -): Map { - const styles = new Map(); - - for (let i = 0; i < cardCount; i++) { - styles.set(i, { - transform: toTransform( - baseLeft, - getCollapsedY(i, selectedIndex, baseTop), - ), - width: cardWidth, - opacity: 1, - transition: 'none', - }); - } - - return styles; -} - -/** - * Create entering animation styles: - * - Selected card animates to the top of the screen - * - Cards above fade out in place - * - Cards below slide down and fade out - */ -export function createEnteringCardStyles( - selectedIndex: number, - cardCount: number, - baseTop: number, - baseLeft: number, - centeredLeft: number, - cardWidth: number, -): Map { - const styles = new Map(); - const ease = `${ANIMATION_DURATION_MS}ms ease-out`; - - // Selected card animates to centered top position - styles.set(selectedIndex, { - transform: toTransform(centeredLeft, CONTENT_TOP), - width: cardWidth, - opacity: 1, - transition: `transform ${ease}`, - }); - - // Cards above fade out in place - for (let i = 0; i < selectedIndex; i++) { - styles.set(i, { - transform: toTransform( - baseLeft, - getCollapsedY(i, selectedIndex, baseTop), - ), - width: cardWidth, - opacity: 0, - transition: `opacity ${ease}`, - }); - } - - // Cards below slide down past card height and fade out - for (let i = selectedIndex + 1; i < cardCount; i++) { - const hiddenY = getCollapsedY(i, selectedIndex, baseTop) + CARD_HEIGHT; - styles.set(i, { - transform: toTransform(baseLeft, hiddenY), - width: cardWidth, - opacity: 0, - transition: `transform ${ease}, opacity ${ease}`, - }); - } - - return styles; -} - -/** - * Create initial styles for exiting animation. - * Positions match the entering animation's final state (selected centered, others hidden). - * Used to ensure CSS transitions have a starting point. - */ -export function createExitingInitialCardStyles( - selectedIndex: number, - cardCount: number, - baseTop: number, - baseLeft: number, - centeredLeft: number, - cardWidth: number, -): Map { - const styles = new Map(); - - for (let i = 0; i < cardCount; i++) { - const isSelected = i === selectedIndex; - const isBelow = i > selectedIndex; - - if (isSelected) { - // Selected card: at centered position (matches entering end state) - styles.set(i, { - transform: toTransform(centeredLeft, CONTENT_TOP), - width: cardWidth, - opacity: 1, - transition: 'none', - }); - } else if (isBelow) { - // Cards below: slid down past card height and faded out - const hiddenY = getCollapsedY(i, selectedIndex, baseTop) + CARD_HEIGHT; - styles.set(i, { - transform: toTransform(baseLeft, hiddenY), - width: cardWidth, - opacity: 0, - transition: 'none', - }); - } else { - // Cards above: at collapsed positions, faded out - styles.set(i, { - transform: toTransform( - baseLeft, - getCollapsedY(i, selectedIndex, baseTop), - ), - width: cardWidth, - opacity: 0, - transition: 'none', - }); - } - } - - return styles; -} - -/** - * Create exiting animation styles - all cards return to collapsed positions. - * Uses cascade delay for cards below to create a "falling into place" effect. - */ -export function createExitingCardStyles( - selectedIndex: number, - cardCount: number, - getCardPosition: (index: number) => CardPosition, -): Map { - const styles = new Map(); - - for (let i = 0; i < cardCount; i++) { - const pos = getCardPosition(i); - const isBelow = i > selectedIndex; - const isSelected = i === selectedIndex; - const cascadeDelay = isBelow - ? (i - selectedIndex) * CASCADE_DELAY_PER_CARD_MS - : 0; - - // Cards above: no transition (appear instantly) - // Selected card: transform transition only - // Cards below: transform with bounce and cascade delay - const transitionStyle: CSSProperties = - isBelow || isSelected - ? { - transitionProperty: 'transform', - transitionDuration: `${ANIMATION_DURATION_MS}ms`, - transitionTimingFunction: isBelow ? BOUNCE_TIMING : 'ease-out', - transitionDelay: cascadeDelay > 0 ? `${cascadeDelay}ms` : '0ms', - } - : {}; - - styles.set(i, { - transform: toTransform(pos.left, pos.top), - width: pos.width, - opacity: 1, - ...transitionStyle, - }); - } - - return styles; -} diff --git a/app/features/gift-cards/card-stack-constants.ts b/app/features/gift-cards/card-stack-constants.ts new file mode 100644 index 000000000..68e0b551c --- /dev/null +++ b/app/features/gift-cards/card-stack-constants.ts @@ -0,0 +1,6 @@ +import { CARD_ASPECT_RATIO, CARD_SIZES } from '~/components/wallet-card'; + +export const VERTICAL_CARD_OFFSET_IN_STACK = 52; + +export const CARD_WIDTH = CARD_SIZES.default.width; +export const CARD_HEIGHT = Math.round(CARD_WIDTH / CARD_ASPECT_RATIO); diff --git a/app/features/gift-cards/card-stack.constants.ts b/app/features/gift-cards/card-stack.constants.ts deleted file mode 100644 index f52bac6c1..000000000 --- a/app/features/gift-cards/card-stack.constants.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { CARD_ASPECT_RATIO, CARD_SIZES } from '~/components/wallet-card'; - -// ============================================================================ -// Animation Timing -// ============================================================================ - -export const ANIMATION_DURATION_MS = 300; -export const CASCADE_DELAY_PER_CARD_MS = 50; -export const BOUNCE_TIMING = 'cubic-bezier(0.34, 1.15, 0.64, 1)'; - -// ============================================================================ -// Layout Dimensions -// ============================================================================ - -/** Header height (56px) + gap (8px) */ -export const CONTENT_TOP = 64; - -export const CARD_WIDTH = CARD_SIZES.default.width; -export const CARD_HEIGHT = Math.round(CARD_WIDTH / CARD_ASPECT_RATIO); - -/** Vertical offset between cards in collapsed stack */ -export const COLLAPSED_OFFSET = 52; diff --git a/app/features/gift-cards/card-stack.tsx b/app/features/gift-cards/card-stack.tsx deleted file mode 100644 index 0ed84a385..000000000 --- a/app/features/gift-cards/card-stack.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useEffect, useMemo, useRef } from 'react'; -import { useSearchParams } from 'react-router'; - -import { - ClosePageButton, - Page, - PageContent, - PageHeader, - PageHeaderTitle, -} from '~/components/page'; -import type { CashuAccount } from '~/features/accounts/account'; - -import { ExpandedView } from './expanded-view'; -import { StackView } from './stack-view'; -import { useCardStackState } from './use-card-stack'; -import { useDiscoverCards } from './use-discover-cards'; - -/** - * Card stack component with expand/collapse animations. - * Displays cards in a stacked view that can be tapped to expand - * and show transaction details. - */ -export function GiftCardsView({ accounts }: { accounts: CashuAccount[] }) { - const cardRefs = useRef<(HTMLButtonElement | null)[]>([]); - const discoverMints = useDiscoverCards(); - const [searchParams] = useSearchParams(); - - // Get initial selection index from URL query param - const initialSelectedIndex = useMemo(() => { - const accountId = searchParams.get('accountId'); - if (!accountId) return null; - const index = accounts.findIndex((a) => a.id === accountId); - return index >= 0 ? index : null; - }, [searchParams, accounts]); - - const { - selectedIndex, - phase, - capturedPosition, - stackedHeight, - selectCard, - collapseStack, - wasInitializedFromUrl, - } = useCardStackState({ - cardCount: accounts.length, - initialSelectedIndex, - cardRefs, - }); - - // Sync selected account ID to URL query params without triggering navigation - useEffect(() => { - const url = new URL(window.location.href); - - if (selectedIndex !== null && accounts[selectedIndex]) { - url.searchParams.set('accountId', accounts[selectedIndex].id); - } else { - url.searchParams.delete('accountId'); - } - - window.history.replaceState(null, '', url.toString()); - }, [selectedIndex, accounts]); - - const showExpanded = - phase !== 'idle' && selectedIndex !== null && capturedPosition !== null; - - const showTitle = phase === 'idle' || phase === 'exiting'; - const isExiting = phase === 'exiting'; - - const handleCloseClick = (e: React.MouseEvent) => { - if (showExpanded) { - e.preventDefault(); - collapseStack(); - } - }; - - return ( - - - - {showTitle && ( - - Gift Cards - - )} - - - - - - {showExpanded && ( - - )} - - - ); -} diff --git a/app/features/gift-cards/discover-section.tsx b/app/features/gift-cards/discover-gift-cards.tsx similarity index 70% rename from app/features/gift-cards/discover-section.tsx rename to app/features/gift-cards/discover-gift-cards.tsx index e48dbeb52..260b96f08 100644 --- a/app/features/gift-cards/discover-section.tsx +++ b/app/features/gift-cards/discover-gift-cards.tsx @@ -2,20 +2,20 @@ import { WalletCard, WalletCardBackground } from '~/components/wallet-card'; import useUserAgent from '~/hooks/use-user-agent'; import { LinkWithViewTransition } from '~/lib/transitions'; import { cn } from '~/lib/utils'; -import type { DiscoverMint } from './use-discover-cards'; +import type { GiftCardInfo } from './use-discover-cards'; type DiscoverSectionProps = { - mints: DiscoverMint[]; + giftCards: GiftCardInfo[]; }; /** * Horizontal scroll carousel of available gift cards for discovery. */ -export function DiscoverSection({ mints }: DiscoverSectionProps) { +export function DiscoverGiftCards({ giftCards }: DiscoverSectionProps) { const { isMobile } = useUserAgent(); return ( -
+

Discover

- {mints.map((mint) => ( + {giftCards.map((card) => ( - + ))} diff --git a/app/features/gift-cards/empty-state.tsx b/app/features/gift-cards/empty-state.tsx index 4dc04abab..52cc0598d 100644 --- a/app/features/gift-cards/empty-state.tsx +++ b/app/features/gift-cards/empty-state.tsx @@ -1,11 +1,10 @@ import { + CARD_SIZES, WalletCard, WalletCardBlank, WalletCardOverlay, } from '~/components/wallet-card'; -import { CARD_WIDTH } from './card-stack.constants'; - /** * Placeholder displayed when user has no gift cards. */ @@ -13,7 +12,10 @@ export function EmptyState() { return ( <>
-
+
diff --git a/app/features/gift-cards/expanded-view.tsx b/app/features/gift-cards/expanded-view.tsx deleted file mode 100644 index 97ee3a32c..000000000 --- a/app/features/gift-cards/expanded-view.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { Button } from '~/components/ui/button'; -import { - type CashuAccount, - getAccountBalance, -} from '~/features/accounts/account'; -import { MoneyWithConvertedAmount } from '~/features/shared/money-with-converted-amount'; -import { TransactionList } from '~/features/transactions/transaction-list'; -import { LinkWithViewTransition } from '~/lib/transitions'; - -import type { CapturedCardPosition } from './card-stack-animation'; -import { CONTENT_TOP } from './card-stack.constants'; -import { GiftCardItem } from './gift-card-item'; -import { type CardStackPhase, useCardAnimationStyles } from './use-card-stack'; -import { getCardImageByMintUrl } from './use-discover-cards'; - -type ExpandedViewProps = { - selectedIndex: number; - selectedAccount: CashuAccount; - phase: CardStackPhase; - capturedPosition: CapturedCardPosition; - accounts: CashuAccount[]; - onCollapse: () => void; - stackViewCardRefs: React.RefObject<(HTMLButtonElement | null)[]>; - /** Skip entrance animations (e.g., when restoring from URL) */ - skipAnimations?: boolean; -}; - -/** - * Expanded view displayed when a card is selected. - * Shows the selected card centered with animated transitions - * and a transaction list below. - */ -export function ExpandedView({ - selectedIndex, - selectedAccount, - phase, - capturedPosition, - accounts, - onCollapse, - stackViewCardRefs, - skipAnimations = false, -}: ExpandedViewProps) { - const { cardStyles, cardWidth } = useCardAnimationStyles( - phase, - selectedIndex, - accounts.length, - capturedPosition, - stackViewCardRefs, - ); - - const isTransitioning = phase === 'entering' || phase === 'exiting'; - const balance = getAccountBalance(selectedAccount); - - return ( -
- {/* Animated cards during transitions */} - {isTransitioning && - accounts.map((account, index) => { - const style = cardStyles.get(index); - if (!style) return null; - const isSelected = index === selectedIndex; - - return ( - - ); - })} - - {/* Scrollable content when settled */} - {phase === 'settled' && ( -
- {/* Selected card */} - - - {/* Balance and actions */} -
- {balance && } - -
- - - - - - -
- - {/* Transaction list */} -
- -
-
-
- )} -
- ); -} diff --git a/app/features/gift-cards/gift-card-details.tsx b/app/features/gift-cards/gift-card-details.tsx new file mode 100644 index 000000000..a84f23d2d --- /dev/null +++ b/app/features/gift-cards/gift-card-details.tsx @@ -0,0 +1,171 @@ +import { X } from 'lucide-react'; +import { useNavigate } from 'react-router'; + +import { + Page, + PageContent, + PageHeader, + PageHeaderItem, +} from '~/components/page'; +import { Button } from '~/components/ui/button'; +import { getAccountBalance } from '~/features/accounts/account'; +import { useAccounts } from '~/features/accounts/account-hooks'; +import { GiftCardItem } from '~/features/gift-cards/gift-card-item'; +import { getGiftCardImageByMintUrl } from '~/features/gift-cards/use-discover-cards'; +import { MoneyWithConvertedAmount } from '~/features/shared/money-with-converted-amount'; +import { TransactionList } from '~/features/transactions/transaction-list'; +import { LinkWithViewTransition } from '~/lib/transitions'; +import { + CARD_HEIGHT, + CARD_WIDTH, + VERTICAL_CARD_OFFSET_IN_STACK, +} from './card-stack-constants'; + +type GiftCardDetailsProps = { + cardId: string; +}; + +export default function GiftCardDetails({ cardId }: GiftCardDetailsProps) { + const navigate = useNavigate(); + + const { data: giftCardAccounts } = useAccounts({ + type: 'cashu', + onlyIncludeClosedLoopAccounts: true, + }); + + const card = giftCardAccounts.find((c) => c.id === cardId); + const selectedIndex = giftCardAccounts.findIndex((c) => c.id === cardId); + + const handleBack = () => { + navigate('/gift-cards', { viewTransition: true }); + }; + + if (!card) { + return ( + +

Card not found

+
+ ); + } + + const balance = getAccountBalance(card); + + return ( + + + + + + + + + {/* Card area - split-stack positioning */} +
+
+ {/* We render all gift cards for view transitions. */} + {giftCardAccounts.map((account, index) => { + const isSelected = account.id === card.id; + const zIndex = index + 1; + const isAtOrBelowSelected = index <= selectedIndex; + + if (isAtOrBelowSelected) { + const item = ( + + ); + + // Cards at or below selected: transition to the top + return ( +
+ {isSelected ? ( + + ) : ( + item + )} +
+ ); + } + + // Cards above selected: transition to off-screen at bottom + const offsetBelowViewport = + (index - selectedIndex - 1) * VERTICAL_CARD_OFFSET_IN_STACK; + return ( +
+ +
+ ); + })} +
+
+ +
+ {balance && } + +
+ + + + + + +
+ +
+ +
+
+
+
+ ); +} diff --git a/app/features/gift-cards/gift-card-item.tsx b/app/features/gift-cards/gift-card-item.tsx index 4b8fc72eb..632810b86 100644 --- a/app/features/gift-cards/gift-card-item.tsx +++ b/app/features/gift-cards/gift-card-item.tsx @@ -11,18 +11,13 @@ import { getAccountBalance, } from '~/features/accounts/account'; import { getDefaultUnit } from '../shared/currencies'; -import { - ANIMATION_DURATION_MS, - COLLAPSED_OFFSET, -} from './card-stack.constants'; +import { VERTICAL_CARD_OFFSET_IN_STACK } from './card-stack-constants'; type GiftCardItemProps = { account: CashuAccount; image?: string; size?: WalletCardSize; className?: string; - /** When true, the info overlay (name, balance, gradient) fades out */ - overlayHidden?: boolean; }; export function GiftCardItem({ @@ -30,7 +25,6 @@ export function GiftCardItem({ image, size, className, - overlayHidden = false, }: GiftCardItemProps) { const balance = getAccountBalance(account); const name = @@ -47,16 +41,14 @@ export function GiftCardItem({
{name} {balance && ( diff --git a/app/features/gift-cards/gift-cards.tsx b/app/features/gift-cards/gift-cards.tsx new file mode 100644 index 000000000..bdd41e11a --- /dev/null +++ b/app/features/gift-cards/gift-cards.tsx @@ -0,0 +1,102 @@ +import { useNavigate } from 'react-router'; +import { + ClosePageButton, + Page, + PageContent, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import type { CashuAccount } from '~/features/accounts/account'; +import { useAccounts } from '../accounts/account-hooks'; +import { + CARD_HEIGHT, + CARD_WIDTH, + VERTICAL_CARD_OFFSET_IN_STACK, +} from './card-stack-constants'; +import { DiscoverGiftCards } from './discover-gift-cards'; +import { EmptyState } from './empty-state'; +import { GiftCardItem } from './gift-card-item'; +import { + getGiftCardImageByMintUrl, + useDiscoverGiftCards, +} from './use-discover-cards'; + +/** + * Gift cards view with discover section and card stack. + * Clicking a card navigates to the card details page with view transitions. + */ +export function GiftCards() { + const { data: accounts, dataUpdatedAt } = useAccounts({ + type: 'cashu', + onlyIncludeClosedLoopAccounts: true, + }); + + // Debug logging to understand reorder issue + console.log('[GiftCards] render', { + dataUpdatedAt, + accountIds: accounts.map((a) => a.id), + accountNames: accounts.map((a) => a.name), + }); + + const navigate = useNavigate(); + const hasCards = accounts.length > 0; + const stackedHeight = + CARD_HEIGHT + (accounts.length - 1) * VERTICAL_CARD_OFFSET_IN_STACK; + const giftCardsToDiscover = useDiscoverGiftCards(); + + const handleCardClick = (account: CashuAccount) => { + navigate(`/gift-cards/${account.id}`, { viewTransition: true }); + }; + + return ( + + + + Gift Cards + + + +
+ {giftCardsToDiscover.length > 0 && ( + + )} + + {hasCards ? ( +
+

Your Cards

+
+
+ {accounts.map((account, index) => ( + + ))} +
+
+
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/app/features/gift-cards/stack-view.tsx b/app/features/gift-cards/stack-view.tsx deleted file mode 100644 index 83b8cbd34..000000000 --- a/app/features/gift-cards/stack-view.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { CashuAccount } from '~/features/accounts/account'; - -import { DiscoverSection } from './discover-section'; -import { EmptyState } from './empty-state'; -import { StackedCards } from './stacked-cards'; -import type { DiscoverMint } from './use-discover-cards'; - -type StackViewProps = { - cardRefs: React.RefObject<(HTMLButtonElement | null)[]>; - accounts: CashuAccount[]; - discoverMints: DiscoverMint[]; - stackedHeight: number; - onCardClick: (index: number, cardRect: DOMRect) => void; - hideCards?: boolean; -}; - -/** - * Main view for the collapsed card stack state. - * Composes the discover carousel, empty state, and stacked cards. - */ -export function StackView({ - cardRefs, - accounts, - discoverMints, - stackedHeight, - onCardClick, - hideCards = false, -}: StackViewProps) { - const hasCards = accounts.length > 0; - - return ( -
- {discoverMints.length > 0 && } - - {hasCards ? ( - - ) : ( - - )} -
- ); -} diff --git a/app/features/gift-cards/stacked-cards.tsx b/app/features/gift-cards/stacked-cards.tsx deleted file mode 100644 index 12b07be47..000000000 --- a/app/features/gift-cards/stacked-cards.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { CashuAccount } from '~/features/accounts/account'; - -import { CARD_WIDTH, COLLAPSED_OFFSET } from './card-stack.constants'; -import { GiftCardItem } from './gift-card-item'; -import { getCardImageByMintUrl } from './use-discover-cards'; - -type StackedCardsProps = { - cardRefs: React.RefObject<(HTMLButtonElement | null)[]>; - accounts: CashuAccount[]; - stackedHeight: number; - onCardClick: (index: number, cardRect: DOMRect) => void; - hideCards: boolean; -}; - -/** - * Renders the collapsed card stack with overlapping cards. - * Each card is positioned absolutely with a vertical offset. - */ -export function StackedCards({ - cardRefs, - accounts, - stackedHeight, - onCardClick, - hideCards, -}: StackedCardsProps) { - return ( -
-

Your Cards

-
-
- {!hideCards && - accounts.map((account, index) => ( - - ))} -
-
-
- ); -} diff --git a/app/features/gift-cards/transitions.css b/app/features/gift-cards/transitions.css new file mode 100644 index 000000000..adebf5975 --- /dev/null +++ b/app/features/gift-cards/transitions.css @@ -0,0 +1,78 @@ +/* Available cards section - fade out/in */ +.view-transition-available { + view-transition-name: available-cards; +} + +::view-transition-old(available-cards), +::view-transition-new(available-cards) { + animation-duration: 0.3s; + animation-timing-function: ease-out; +} + +::view-transition-old(available-cards) { + animation-name: fade-out; +} + +::view-transition-new(available-cards) { + animation-name: fade-in; +} + +/* Transactions section - slide up/down with fade */ + +/* Slide + fade animations for transactions */ +@keyframes slide-fade-out { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(20px); + } +} + +@keyframes slide-fade-in { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.view-transition-transactions { + view-transition-name: transactions; +} + +::view-transition-old(transactions), +::view-transition-new(transactions) { + animation-duration: 0.35s; + animation-timing-function: ease-out; +} + +::view-transition-old(transactions) { + animation-name: slide-fade-out; +} + +::view-transition-new(transactions) { + animation-name: slide-fade-in; +} + +/* Transactions should be below cards during transition */ +::view-transition-group(transactions) { + z-index: 1; +} + +/* Card transitions - morph between positions */ +::view-transition-group(available-cards), +::view-transition-group(transactions) { + animation-duration: 0.4s; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Ensure cards maintain their stacking during transition */ +::view-transition-image-pair(*) { + isolation: auto; +} diff --git a/app/features/gift-cards/use-card-stack.ts b/app/features/gift-cards/use-card-stack.ts deleted file mode 100644 index ab2309226..000000000 --- a/app/features/gift-cards/use-card-stack.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { - type CSSProperties, - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from 'react'; -import { useTimeout } from 'usehooks-ts'; - -import { - type CapturedCardPosition, - type CardPosition, - createEnteringCardStyles, - createExitingCardStyles, - createExitingInitialCardStyles, - createInitialCardStyles, - getCollapsedY, -} from './card-stack-animation'; -import { - ANIMATION_DURATION_MS, - CARD_HEIGHT, - CASCADE_DELAY_PER_CARD_MS, - COLLAPSED_OFFSET, -} from './card-stack.constants'; - -/** - * Card stack animation phases: - * - idle: No card selected, showing collapsed stack - * - entering: Card expanding to center, others fading/sliding away - * - settled: Card centered, transactions visible - * - exiting: Card returning to stack, others cascading back - */ -export type CardStackPhase = 'idle' | 'entering' | 'settled' | 'exiting'; - -type CardStackState = { - selectedIndex: number | null; - phase: CardStackPhase; - capturedPosition: CapturedCardPosition | null; - stackedHeight: number; - selectCard: (index: number, cardRect: DOMRect) => void; - collapseStack: () => void; - /** True if the card was initialized from URL (skip animations) */ - wasInitializedFromUrl: boolean; -}; - -type CardAnimationStyles = { - cardStyles: Map; - cardWidth: number; - cardHeight: number; - centeredLeft: number; -}; - -type UseCardStackStateOptions = { - cardCount: number; - /** Initial selection index to restore on mount (e.g., from URL) */ - initialSelectedIndex?: number | null; - /** Ref to card buttons for getting initial position */ - cardRefs?: React.RefObject<(HTMLButtonElement | null)[]>; -}; - -/** - * Manages card stack state machine and phase transitions. - * Controls when cards expand (entering → settled) and collapse (exiting → idle). - */ -export function useCardStackState({ - cardCount, - initialSelectedIndex, - cardRefs, -}: UseCardStackStateOptions): CardStackState { - const [selectedIndex, setSelectedIndex] = useState(null); - const [phase, setPhase] = useState('idle'); - const [capturedPosition, setCapturedPosition] = - useState(null); - const hasInitialized = useRef(false); - const [wasInitializedFromUrl, setWasInitializedFromUrl] = useState(false); - - // Declarative timeout delays - null means no active timeout - const [enterDelay, setEnterDelay] = useState(null); - const [exitDelay, setExitDelay] = useState(null); - - // Restore selection from initial index (e.g., from URL query param) - // Skips animation and goes directly to settled state - useEffect(() => { - if ( - hasInitialized.current || - initialSelectedIndex == null || - initialSelectedIndex < 0 || - initialSelectedIndex >= cardCount - ) { - return; - } - - const refs = cardRefs?.current; - const cardRef = refs?.[initialSelectedIndex]; - if (!cardRef) return; - - hasInitialized.current = true; - const rect = cardRef.getBoundingClientRect(); - - setCapturedPosition({ - index: initialSelectedIndex, - top: rect.top, - left: rect.left, - width: rect.width, - }); - setSelectedIndex(initialSelectedIndex); - setPhase('settled'); - setWasInitializedFromUrl(true); - }, [initialSelectedIndex, cardCount, cardRefs]); - - // useTimeout handles cleanup automatically on unmount or when delay changes - useTimeout(() => { - setPhase('settled'); - setEnterDelay(null); - }, enterDelay); - - useTimeout(() => { - setPhase('idle'); - setSelectedIndex(null); - setCapturedPosition(null); - setExitDelay(null); - }, exitDelay); - - const selectCard = useCallback((index: number, cardRect: DOMRect) => { - setCapturedPosition({ - index, - top: cardRect.top, - left: cardRect.left, - width: cardRect.width, - }); - setSelectedIndex(index); - setPhase('entering'); - setEnterDelay(ANIMATION_DURATION_MS); - }, []); - - // Collapse timing accounts for cascade delay on cards below the selected one. - // Each card below animates with an additional CASCADE_DELAY_PER_CARD_MS delay, - // creating a "falling into place" effect. - const collapseStack = useCallback(() => { - if (selectedIndex === null) return; - setPhase('exiting'); - - const cardsBelow = cardCount - selectedIndex - 1; - const totalExitTime = - ANIMATION_DURATION_MS + cardsBelow * CASCADE_DELAY_PER_CARD_MS; - - setExitDelay(totalExitTime); - }, [cardCount, selectedIndex]); - - return { - selectedIndex, - phase, - capturedPosition, - stackedHeight: CARD_HEIGHT + (cardCount - 1) * COLLAPSED_OFFSET, - selectCard, - collapseStack, - wasInitializedFromUrl, - }; -} - -/** - * Computes CSS styles for each card during expand/collapse animations. - * Returns a Map of card index → CSSProperties for positioning and transitions. - */ -export function useCardAnimationStyles( - phase: CardStackPhase, - selectedIndex: number, - cardCount: number, - capturedPosition: CapturedCardPosition, - stackViewCardRefs: React.RefObject<(HTMLButtonElement | null)[]>, -): CardAnimationStyles { - const [cardStyles, setCardStyles] = useState>( - new Map(), - ); - const [isInitialRender, setIsInitialRender] = useState(true); - - const { top: baseTop, left: baseLeft, width: baseWidth } = capturedPosition; - - // Track centered position in state to avoid hydration mismatch. - // Initialize with baseLeft (matches SSR), then update after mount. - const [centeredLeft, setCenteredLeft] = useState(baseLeft); - useLayoutEffect(() => { - setCenteredLeft((window.innerWidth - baseWidth) / 2); - }, [baseWidth]); - - // Set initial positions (no animation) - must happen before paint - useLayoutEffect(() => { - if (phase === 'entering') { - setCardStyles( - createInitialCardStyles( - selectedIndex, - cardCount, - baseTop, - baseLeft, - baseWidth, - ), - ); - setIsInitialRender(true); - } - }, [phase, baseTop, baseLeft, baseWidth, selectedIndex, cardCount]); - - // Double requestAnimationFrame ensures initial styles are painted before - // applying animated styles. First rAF schedules for next frame, second rAF - // ensures the browser has actually painted the initial state. - useEffect(() => { - if (phase === 'entering' && isInitialRender) { - let innerFrameId: number; - let cancelled = false; - - const outerFrameId = requestAnimationFrame(() => { - if (cancelled) return; - innerFrameId = requestAnimationFrame(() => { - if (cancelled) return; - setCardStyles( - createEnteringCardStyles( - selectedIndex, - cardCount, - baseTop, - baseLeft, - centeredLeft, - baseWidth, - ), - ); - setIsInitialRender(false); - }); - }); - - return () => { - cancelled = true; - cancelAnimationFrame(outerFrameId); - cancelAnimationFrame(innerFrameId); - }; - } - }, [ - phase, - isInitialRender, - centeredLeft, - selectedIndex, - cardCount, - baseTop, - baseLeft, - baseWidth, - ]); - - // Handle exiting animation - set initial positions first (synchronous). - // This ensures cards start at their "expanded" positions before animating to collapsed. - useLayoutEffect(() => { - if (phase !== 'exiting') return; - - setCardStyles( - createExitingInitialCardStyles( - selectedIndex, - cardCount, - baseTop, - baseLeft, - centeredLeft, - baseWidth, - ), - ); - setIsInitialRender(true); - }, [ - phase, - centeredLeft, - baseTop, - baseLeft, - baseWidth, - selectedIndex, - cardCount, - ]); - - // Handle exiting animation - all cards return to collapsed positions. - // Uses cascade delay for cards below to create a "falling into place" effect. - // Double rAF ensures initial styles are painted before applying animated styles. - useEffect(() => { - if (phase !== 'exiting' || !isInitialRender) return; - - let innerFrameId: number; - let cancelled = false; - - const outerFrameId = requestAnimationFrame(() => { - if (cancelled) return; - innerFrameId = requestAnimationFrame(() => { - if (cancelled) return; - - const getCardPosition = (index: number): CardPosition => { - const refs = stackViewCardRefs.current; - const ref = - refs && index >= 0 && index < refs.length ? refs[index] : null; - if (ref) { - const rect = ref.getBoundingClientRect(); - return { top: rect.top, left: rect.left, width: rect.width }; - } - // Fallback: calculate position relative to captured card if ref is unavailable - return { - top: getCollapsedY(index, selectedIndex, baseTop), - left: baseLeft, - width: baseWidth, - }; - }; - - setCardStyles( - createExitingCardStyles(selectedIndex, cardCount, getCardPosition), - ); - setIsInitialRender(false); - }); - }); - - return () => { - cancelled = true; - cancelAnimationFrame(outerFrameId); - cancelAnimationFrame(innerFrameId); - }; - }, [ - phase, - isInitialRender, - baseTop, - baseLeft, - baseWidth, - selectedIndex, - cardCount, - stackViewCardRefs, - ]); - - return { - cardStyles, - cardWidth: baseWidth, - cardHeight: CARD_HEIGHT, - centeredLeft, - }; -} diff --git a/app/features/gift-cards/use-discover-cards.ts b/app/features/gift-cards/use-discover-cards.ts index 24a8f50c9..8063125d5 100644 --- a/app/features/gift-cards/use-discover-cards.ts +++ b/app/features/gift-cards/use-discover-cards.ts @@ -8,7 +8,7 @@ import theShackCard from '~/assets/gift-cards/shack.agi.cash.png'; import type { Currency } from '~/lib/money'; import { useAccounts } from '../accounts/account-hooks'; -export type DiscoverMint = { +export type GiftCardInfo = { url: string; name: string; image: string; @@ -16,9 +16,9 @@ export type DiscoverMint = { }; /** - * Hardcoded list of mints available for discovery. + * Hardcoded list of gift cardsavailable for discovery. */ -export const DISCOVER_MINTS: DiscoverMint[] = [ +export const GIFT_CARDS: GiftCardInfo[] = [ { url: 'https://blockandbean.agi.cash', name: 'Block and Bean', @@ -58,26 +58,25 @@ export const DISCOVER_MINTS: DiscoverMint[] = [ ]; /** - * Returns the card image for a given mint URL, if one exists. + * Returns the gift card image for a given URL, if one exists. */ -export function getCardImageByMintUrl(mintUrl: string): string | undefined { - return DISCOVER_MINTS.find((mint) => mint.url === mintUrl)?.image; +export function getGiftCardImageByMintUrl(url: string): string | undefined { + return GIFT_CARDS.find((card) => card.url === url)?.image; } /** - * Returns discover cards that the user has not yet added. - * Filters out mints where the user already has an account with matching url and currency. + * Returns gift cards that the user has not yet added. */ -export function useDiscoverCards(): DiscoverMint[] { +export function useDiscoverGiftCards(): GiftCardInfo[] { const { data: cashuAccounts } = useAccounts({ type: 'cashu' }); return useMemo(() => { - const existingMints = new Set( + const existingGiftCardAccounts = new Set( cashuAccounts.map((account) => `${account.mintUrl}:${account.currency}`), ); - return DISCOVER_MINTS.filter( - (mint) => !existingMints.has(`${mint.url}:${mint.currency}`), + return GIFT_CARDS.filter( + (mint) => !existingGiftCardAccounts.has(`${mint.url}:${mint.currency}`), ); }, [cashuAccounts]); } diff --git a/app/routes/_protected.gift-cards.$cardId.tsx b/app/routes/_protected.gift-cards.$cardId.tsx new file mode 100644 index 000000000..544150003 --- /dev/null +++ b/app/routes/_protected.gift-cards.$cardId.tsx @@ -0,0 +1,6 @@ +import GiftCardDetails from '~/features/gift-cards/gift-card-details'; +import type { Route } from './+types/_protected.gift-cards.$cardId'; + +export default function GiftCardDetailsRoute({ params }: Route.ComponentProps) { + return ; +} diff --git a/app/routes/_protected.gift-cards._index.tsx b/app/routes/_protected.gift-cards._index.tsx index e665c684b..b64bfffc5 100644 --- a/app/routes/_protected.gift-cards._index.tsx +++ b/app/routes/_protected.gift-cards._index.tsx @@ -1,11 +1,5 @@ -import { useAccounts } from '~/features/accounts/account-hooks'; -import { GiftCardsView } from '~/features/gift-cards/card-stack'; +import { GiftCards } from '~/features/gift-cards/gift-cards'; -export default function GiftCardsIndex() { - const { data: giftCardAccounts } = useAccounts({ - type: 'cashu', - onlyIncludeClosedLoopAccounts: true, - }); - - return ; +export default function GiftCardsRoute() { + return ; } diff --git a/app/routes/_protected.gift-cards.tsx b/app/routes/_protected.gift-cards.tsx index 9203dc45a..5a9a27cfd 100644 --- a/app/routes/_protected.gift-cards.tsx +++ b/app/routes/_protected.gift-cards.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Outlet } from 'react-router'; import { createTransactionAckStatusStore } from '~/features/transactions/transaction-ack-status-store'; +import '~/features/gift-cards/transitions.css'; export default function GiftCardsLayout() { const [store] = useState(() => createTransactionAckStatusStore()); diff --git a/app/routes/_protected.gift-cards_.add.$mintUrl.$currency.tsx b/app/routes/_protected.gift-cards_.add.$mintUrl.$currency.tsx index 069e3d349..4a40781d4 100644 --- a/app/routes/_protected.gift-cards_.add.$mintUrl.$currency.tsx +++ b/app/routes/_protected.gift-cards_.add.$mintUrl.$currency.tsx @@ -1,9 +1,29 @@ import { AddGiftCard } from '~/features/gift-cards/add-gift-card'; -import type { Currency } from '~/lib/money/types'; +import { GIFT_CARDS } from '~/features/gift-cards/use-discover-cards'; +import { LoadingScreen } from '~/features/loading/LoadingScreen'; +import { NotFoundError } from '~/features/shared/error'; import type { Route } from './+types/_protected.gift-cards_.add.$mintUrl.$currency'; -export default function AddGiftCardPage({ - params: { mintUrl, currency }, -}: Route.ComponentProps) { - return ; +export const clientLoader = ({ params }: Route.ComponentProps) => { + const { mintUrl, currency } = params; + const giftCard = GIFT_CARDS.find( + (card) => card.url === mintUrl && card.currency === currency, + ); + if (!giftCard) { + throw new NotFoundError('Gift card not found'); + } + + return { giftCard }; +}; + +clientLoader.hydrate = true as const; + +export function HydrateFallback() { + return ; +} + +export default function AddGiftCardRoute({ loaderData }: Route.ComponentProps) { + const { giftCard } = loaderData; + + return ; } From ec65137868b3b1409eb9b569ef414a0a73d9d963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josip=20Boj=C4=8Di=C4=87?= Date: Mon, 12 Jan 2026 20:48:45 +0100 Subject: [PATCH 2/3] Cleanup --- app/features/gift-cards/gift-cards.tsx | 9 +-------- app/features/gift-cards/transitions.css | 17 ----------------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/app/features/gift-cards/gift-cards.tsx b/app/features/gift-cards/gift-cards.tsx index bdd41e11a..afc7bfe8d 100644 --- a/app/features/gift-cards/gift-cards.tsx +++ b/app/features/gift-cards/gift-cards.tsx @@ -26,18 +26,11 @@ import { * Clicking a card navigates to the card details page with view transitions. */ export function GiftCards() { - const { data: accounts, dataUpdatedAt } = useAccounts({ + const { data: accounts } = useAccounts({ type: 'cashu', onlyIncludeClosedLoopAccounts: true, }); - // Debug logging to understand reorder issue - console.log('[GiftCards] render', { - dataUpdatedAt, - accountIds: accounts.map((a) => a.id), - accountNames: accounts.map((a) => a.name), - }); - const navigate = useNavigate(); const hasCards = accounts.length > 0; const stackedHeight = diff --git a/app/features/gift-cards/transitions.css b/app/features/gift-cards/transitions.css index adebf5975..aa9bf2612 100644 --- a/app/features/gift-cards/transitions.css +++ b/app/features/gift-cards/transitions.css @@ -59,20 +59,3 @@ ::view-transition-new(transactions) { animation-name: slide-fade-in; } - -/* Transactions should be below cards during transition */ -::view-transition-group(transactions) { - z-index: 1; -} - -/* Card transitions - morph between positions */ -::view-transition-group(available-cards), -::view-transition-group(transactions) { - animation-duration: 0.4s; - animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); -} - -/* Ensure cards maintain their stacking during transition */ -::view-transition-image-pair(*) { - isolation: auto; -} From 06df8917ceb019e9fac91ba19a80516d20233f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josip=20Boj=C4=8Di=C4=87?= Date: Mon, 12 Jan 2026 21:44:40 +0100 Subject: [PATCH 3/3] Add back fade in/out transition for gift card item overlay --- app/features/gift-cards/gift-card-details.tsx | 1 + app/features/gift-cards/gift-card-item.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/features/gift-cards/gift-card-details.tsx b/app/features/gift-cards/gift-card-details.tsx index a84f23d2d..7fcb61f4a 100644 --- a/app/features/gift-cards/gift-card-details.tsx +++ b/app/features/gift-cards/gift-card-details.tsx @@ -82,6 +82,7 @@ export default function GiftCardDetails({ cardId }: GiftCardDetailsProps) { account={account} image={getGiftCardImageByMintUrl(account.mintUrl)} className="w-full max-w-none" + hideOverlayContent={isSelected} /> ); diff --git a/app/features/gift-cards/gift-card-item.tsx b/app/features/gift-cards/gift-card-item.tsx index 632810b86..d43e93adf 100644 --- a/app/features/gift-cards/gift-card-item.tsx +++ b/app/features/gift-cards/gift-card-item.tsx @@ -18,6 +18,7 @@ type GiftCardItemProps = { image?: string; size?: WalletCardSize; className?: string; + hideOverlayContent?: boolean; }; export function GiftCardItem({ @@ -25,6 +26,7 @@ export function GiftCardItem({ image, size, className, + hideOverlayContent, }: GiftCardItemProps) { const balance = getAccountBalance(account); const name = @@ -48,7 +50,11 @@ export function GiftCardItem({ >
{name} {balance && (