diff --git a/app/features/accounts/account-icons.tsx b/app/features/accounts/account-icons.tsx index 898f6ed2d..8d55a3c6c 100644 --- a/app/features/accounts/account-icons.tsx +++ b/app/features/accounts/account-icons.tsx @@ -16,7 +16,7 @@ export function AccountIcon({ type, purpose, }: { type: AccountType; purpose: AccountPurpose }) { - if (purpose === 'gift-card') { + if (purpose === 'gift-card' || purpose === 'offer') { return ; } return iconsByAccountType[type]; diff --git a/app/features/gift-cards/discover-gift-cards.tsx b/app/features/gift-cards/discover-gift-cards.tsx index 427cbf377..be7fbe782 100644 --- a/app/features/gift-cards/discover-gift-cards.tsx +++ b/app/features/gift-cards/discover-gift-cards.tsx @@ -1,11 +1,4 @@ -import { useEffect, useRef } from 'react'; -import { - Link, - useLocation, - useNavigate, - useViewTransitionState, -} from 'react-router'; -import { z } from 'zod'; +import { Link, useNavigate, useViewTransitionState } from 'react-router'; import { WalletCard, WalletCardBackgroundImage, @@ -13,40 +6,11 @@ import { import useUserAgent from '~/hooks/use-user-agent'; import { cn } from '~/lib/utils'; import type { GiftCardInfo } from './gift-card-config'; - -const DiscoverCardsLocationStateSchema = z.object({ - discoverScrollPosition: z.number(), -}); - -/** - * Restores scroll position from navigation state when returning from add-gift-card page. - * Returns the current scroll position for passing to child Link components. - */ -function useRestoreScrollPosition() { - const location = useLocation(); - const scrollRef = useRef(null); - const scrollPositionRef = useRef(0); - - // Restore scroll position from navigation state - useEffect(() => { - const result = DiscoverCardsLocationStateSchema.safeParse(location.state); - if (result.success && scrollRef.current) { - scrollRef.current.scrollLeft = result.data.discoverScrollPosition; - } - }, [location.state]); - - const handleScroll = () => { - if (scrollRef.current) { - scrollPositionRef.current = scrollRef.current.scrollLeft; - } - }; - - return { scrollRef, scrollPositionRef, handleScroll }; -} +import { useRestoreScrollPosition } from './use-restore-scroll-position'; type DiscoverCardLinkProps = { card: GiftCardInfo; - scrollPositionRef: React.RefObject; + getScrollState: () => object; children: React.ReactNode; }; @@ -56,7 +20,7 @@ type DiscoverCardLinkProps = { */ function DiscoverCardLink({ card, - scrollPositionRef, + getScrollState, children, }: DiscoverCardLinkProps) { const navigate = useNavigate(); @@ -67,12 +31,7 @@ function DiscoverCardLink({ // Use onClick to capture scroll position at click time, not render time const handleClick = (e: React.MouseEvent) => { e.preventDefault(); - navigate(to, { - viewTransition: true, - state: { - discoverScrollPosition: scrollPositionRef.current, - } satisfies z.input, - }); + navigate(to, { viewTransition: true, state: getScrollState() }); }; return ( @@ -101,8 +60,9 @@ type DiscoverSectionProps = { export function DiscoverGiftCards({ giftCards }: DiscoverSectionProps) { const { isMobile } = useUserAgent(); const isTransitioning = useViewTransitionState('/gift-cards/:accountId'); - const { scrollRef, scrollPositionRef, handleScroll } = - useRestoreScrollPosition(); + const { scrollRef, handleScroll, getScrollState } = useRestoreScrollPosition( + 'discoverScrollPosition', + ); return (
0; const stackedHeight = @@ -47,52 +44,20 @@ export function GiftCards() { navigate(`/gift-cards/${account.id}`, { viewTransition: true }); }; - const handleOfferClick = (account: CashuAccount) => { - navigate(`/gift-cards/offers/${account.id}`, { viewTransition: true }); - }; - return ( - + Gift Cards - +
{giftCardsToDiscover.length > 0 && ( )} - {offerCards.length > 0 && ( -
-

Offers

-
- {offerCards.map((account) => ( - - ))} -
-
- )} + {hasCards ? (
diff --git a/app/features/gift-cards/offers-section.tsx b/app/features/gift-cards/offers-section.tsx new file mode 100644 index 000000000..8ebfd64f0 --- /dev/null +++ b/app/features/gift-cards/offers-section.tsx @@ -0,0 +1,156 @@ +import { useNavigate, useViewTransitionState } from 'react-router'; +import { + WalletCard, + WalletCardBackgroundImage, + WalletCardBlank, + WalletCardOverlay, +} from '~/components/wallet-card'; +import type { CashuAccount } from '~/features/accounts/account'; +import useUserAgent from '~/hooks/use-user-agent'; +import { cn } from '~/lib/utils'; +import { CARD_WIDTH } from './card-stack-constants'; +import { getGiftCardImageByUrl } from './gift-card-images'; +import { useRestoreScrollPosition } from './use-restore-scroll-position'; + +type OfferCardButtonProps = { + account: CashuAccount; + size?: 'default' | 'sm'; + isTransitioning: boolean; + onClick: () => void; + className?: string; + cardClassName?: string; +}; + +function OfferCardButton({ + account, + size = 'default', + isTransitioning, + onClick, + className, + cardClassName, +}: OfferCardButtonProps) { + const image = getGiftCardImageByUrl(account.mintUrl); + return ( + + ); +} + +type OffersSectionProps = { + offers: CashuAccount[]; +}; + +export function OffersSection({ offers }: OffersSectionProps) { + const navigate = useNavigate(); + const isTransitioning = useViewTransitionState( + '/gift-cards/offers/:accountId', + ); + const { isMobile } = useUserAgent(); + const { scrollRef, handleScroll, getScrollState } = useRestoreScrollPosition( + 'offersScrollPosition', + ); + + const navigateToOffer = (account: CashuAccount) => { + navigate(`/gift-cards/offers/${account.id}`, { + viewTransition: true, + state: getScrollState(), + }); + }; + + if (offers.length === 0) return null; + + if (offers.length === 1) { + const offer = offers[0]; + return ( +
+

Offers

+
+ navigateToOffer(offer)} + className="w-full" + /> +
+
+ ); + } + + if (offers.length === 2) { + return ( +
+

Offers

+
+ {offers.map((offer) => ( + navigateToOffer(offer)} + className="min-w-0 flex-1" + /> + ))} +
+
+ ); + } + + return ( +
+

Offers

+
+
+
+ {offers.map((offer, index) => ( + navigateToOffer(offer)} + cardClassName={cn( + index === 0 && 'ml-4 sm:ml-0', + index === offers.length - 1 && 'mr-4 sm:mr-0', + )} + /> + ))} +
+
+
+
+ ); +} diff --git a/app/features/gift-cards/use-restore-scroll-position.ts b/app/features/gift-cards/use-restore-scroll-position.ts new file mode 100644 index 000000000..e64e07417 --- /dev/null +++ b/app/features/gift-cards/use-restore-scroll-position.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router'; +import { z } from 'zod'; + +/** + * Persists and restores the horizontal scroll position of a container across + * navigations. Positions are stored in `location.state` under `state.scrollPositions[stateKey]`. + * Pass `getScrollState()` as the `state` argument to `navigate(...)` when + * leaving the page so the position gets restored on back navigation. + */ +export function useRestoreScrollPosition(stateKey: K) { + const location = useLocation(); + const scrollRef = useRef(null); + const scrollPositionRef = useRef(0); + + useEffect(() => { + const result = z + .object({ scrollPositions: z.object({ [stateKey]: z.number() }) }) + .safeParse(location.state); + + if (result.success && scrollRef.current) { + scrollRef.current.scrollLeft = result.data.scrollPositions[stateKey]; + } + }, [location.state, stateKey]); + + const handleScroll = () => { + if (scrollRef.current) { + scrollPositionRef.current = scrollRef.current.scrollLeft; + } + }; + + const getScrollState = () => { + const existing = z + .object({ scrollPositions: z.record(z.string(), z.number()) }) + .safeParse(location.state); + return { + scrollPositions: { + ...(existing.success ? existing.data.scrollPositions : {}), + [stateKey]: scrollPositionRef.current, + }, + }; + }; + + return { scrollRef, handleScroll, getScrollState }; +} diff --git a/app/features/receive/receive-scanner.tsx b/app/features/receive/receive-scanner.tsx index b6f20ac15..ed067536d 100644 --- a/app/features/receive/receive-scanner.tsx +++ b/app/features/receive/receive-scanner.tsx @@ -1,5 +1,5 @@ import { - PageBackButton, + ClosePageButton, PageContent, PageHeader, PageHeaderTitle, @@ -20,7 +20,7 @@ export default function ReceiveScanner() { return ( <> - - - - + Scan diff --git a/bunfig.toml b/bunfig.toml index d38314e06..86ed0c75d 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,6 @@ [test] -root = "./app" \ No newline at end of file +root = "./app" + +[install] +# 3 days, in seconds — see https://bun.com/docs/runtime/bunfig#install-minimumreleaseage +minimumReleaseAge = 259200 \ No newline at end of file