From bdf2f6846517801b8ae38e5a723903f00f3b5f31 Mon Sep 17 00:00:00 2001 From: Ditto Date: Tue, 12 May 2026 12:59:46 +0200 Subject: [PATCH 1/7] chore: add 3-day minimum release age for npm packages Reduces exposure to supply chain attacks via freshly published malicious versions by filtering out npm releases younger than 3 days during install. Co-Authored-By: Claude Opus 4.7 (1M context) --- bunfig.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 From 92a867d3c38617dee26dbf6f5ad594ee7d8f9d64 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 5 May 2026 15:38:54 -0700 Subject: [PATCH 2/7] feat: scale offers section by count Render the offers section on the gift cards page differently based on offer count: 1 offer stays full-sized, 2 offers go side-by-side at half width, 3+ become a horizontal scroll carousel at discover-card size. Extract the discover section's scroll-position-restoration hook into a shared useRestoreScrollPosition keyed by location.state name, used by both DiscoverGiftCards and the new OffersSection. --- .../gift-cards/discover-gift-cards.tsx | 58 +------ app/features/gift-cards/gift-cards.tsx | 39 +---- app/features/gift-cards/offers-section.tsx | 159 ++++++++++++++++++ .../gift-cards/use-restore-scroll-position.ts | 39 +++++ 4 files changed, 209 insertions(+), 86 deletions(-) create mode 100644 app/features/gift-cards/offers-section.tsx create mode 100644 app/features/gift-cards/use-restore-scroll-position.ts 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,10 +44,6 @@ export function GiftCards() { navigate(`/gift-cards/${account.id}`, { viewTransition: true }); }; - const handleOfferClick = (account: CashuAccount) => { - navigate(`/gift-cards/offers/${account.id}`, { viewTransition: true }); - }; - return ( @@ -64,35 +57,7 @@ export function GiftCards() { )} - {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..c57356182 --- /dev/null +++ b/app/features/gift-cards/offers-section.tsx @@ -0,0 +1,159 @@ +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, + includeScrollState: boolean, + ) => { + navigate(`/gift-cards/offers/${account.id}`, { + viewTransition: true, + state: includeScrollState ? getScrollState() : undefined, + }); + }; + + if (offers.length === 0) return null; + + if (offers.length === 1) { + const offer = offers[0]; + return ( +
+

Offers

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

Offers

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

Offers

+
+
+
+ {offers.map((offer, index) => ( + navigateToOffer(offer, true)} + 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..eabe60b25 --- /dev/null +++ b/app/features/gift-cards/use-restore-scroll-position.ts @@ -0,0 +1,39 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useLocation } from 'react-router'; +import { z } from 'zod'; + +/** + * Persists and restores the horizontal scroll position of a container across + * navigations, keyed by `stateKey` in `location.state`. 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); + + const schema = useMemo( + () => z.object({ [stateKey]: z.number() }), + [stateKey], + ); + + useEffect(() => { + const result = schema.safeParse(location.state); + if (result.success && scrollRef.current) { + const value = (result.data as Record)[stateKey]; + scrollRef.current.scrollLeft = value; + } + }, [location.state, schema, stateKey]); + + const handleScroll = () => { + if (scrollRef.current) { + scrollPositionRef.current = scrollRef.current.scrollLeft; + } + }; + + const getScrollState = () => + ({ [stateKey]: scrollPositionRef.current }) as Record; + + return { scrollRef, handleScroll, getScrollState }; +} From 4d75a61f5114528b33c13606a09bd47944bb29e7 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 12 May 2026 12:07:44 -0700 Subject: [PATCH 3/7] fix: preserve sibling scroll state across gift-card navigations Merge existing location.state in getScrollState so Discover and Offers sections forward-carry each other's scroll position on navigation. Drop the includeScrollState boolean in OffersSection so the 1/2-offer paths no longer wipe discoverScrollPosition. Inline the zod schema and remove the useMemo per review feedback. --- app/features/gift-cards/offers-section.tsx | 13 ++++------ .../gift-cards/use-restore-scroll-position.ts | 25 ++++++++++--------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/app/features/gift-cards/offers-section.tsx b/app/features/gift-cards/offers-section.tsx index c57356182..8ebfd64f0 100644 --- a/app/features/gift-cards/offers-section.tsx +++ b/app/features/gift-cards/offers-section.tsx @@ -75,13 +75,10 @@ export function OffersSection({ offers }: OffersSectionProps) { 'offersScrollPosition', ); - const navigateToOffer = ( - account: CashuAccount, - includeScrollState: boolean, - ) => { + const navigateToOffer = (account: CashuAccount) => { navigate(`/gift-cards/offers/${account.id}`, { viewTransition: true, - state: includeScrollState ? getScrollState() : undefined, + state: getScrollState(), }); }; @@ -96,7 +93,7 @@ export function OffersSection({ offers }: OffersSectionProps) { navigateToOffer(offer, false)} + onClick={() => navigateToOffer(offer)} className="w-full" />
@@ -114,7 +111,7 @@ export function OffersSection({ offers }: OffersSectionProps) { key={offer.id} account={offer} isTransitioning={isTransitioning} - onClick={() => navigateToOffer(offer, false)} + onClick={() => navigateToOffer(offer)} className="min-w-0 flex-1" /> ))} @@ -144,7 +141,7 @@ export function OffersSection({ offers }: OffersSectionProps) { account={offer} size="sm" isTransitioning={isTransitioning} - onClick={() => navigateToOffer(offer, true)} + onClick={() => 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 index eabe60b25..58439c2d0 100644 --- a/app/features/gift-cards/use-restore-scroll-position.ts +++ b/app/features/gift-cards/use-restore-scroll-position.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router'; import { z } from 'zod'; @@ -13,18 +13,15 @@ export function useRestoreScrollPosition(stateKey: K) { const scrollRef = useRef(null); const scrollPositionRef = useRef(0); - const schema = useMemo( - () => z.object({ [stateKey]: z.number() }), - [stateKey], - ); - useEffect(() => { - const result = schema.safeParse(location.state); + const result = z + .object({ [stateKey]: z.number() }) + .safeParse(location.state); + if (result.success && scrollRef.current) { - const value = (result.data as Record)[stateKey]; - scrollRef.current.scrollLeft = value; + scrollRef.current.scrollLeft = result.data[stateKey]; } - }, [location.state, schema, stateKey]); + }, [location.state, stateKey]); const handleScroll = () => { if (scrollRef.current) { @@ -32,8 +29,12 @@ export function useRestoreScrollPosition(stateKey: K) { } }; - const getScrollState = () => - ({ [stateKey]: scrollPositionRef.current }) as Record; + const getScrollState = () => ({ + ...(typeof location.state === 'object' && location.state !== null + ? location.state + : {}), + [stateKey]: scrollPositionRef.current, + }); return { scrollRef, handleScroll, getScrollState }; } From 25c4b3f1acf96f7363fdbd8a98d0d965b18b251c Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 12 May 2026 11:01:54 -0700 Subject: [PATCH 4/7] ui: use close button on scanner routes --- app/features/receive/receive-scanner.tsx | 4 ++-- app/features/send/send-scanner.tsx | 4 ++-- app/features/transfer/transfer-scanner.tsx | 4 ++-- app/routes/_protected.scan.tsx | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) 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 From f9a99740b1706682881fd92632743ad115b97882 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 12 May 2026 14:43:53 -0700 Subject: [PATCH 5/7] Offers icon should be Gift, not Bank icon --- app/features/accounts/account-icons.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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]; From 9aa63cbd9e14e7995cb4311cda7f295493d65630 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 12 May 2026 14:52:16 -0700 Subject: [PATCH 6/7] Solid page header on /gift-cards --- app/features/gift-cards/gift-cards.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/features/gift-cards/gift-cards.tsx b/app/features/gift-cards/gift-cards.tsx index 231ff4e96..3c0892919 100644 --- a/app/features/gift-cards/gift-cards.tsx +++ b/app/features/gift-cards/gift-cards.tsx @@ -46,12 +46,12 @@ export function GiftCards() { return ( - + Gift Cards - +
{giftCardsToDiscover.length > 0 && ( From 9146517e51fd6069afe563d0afeb33011f6d8e2b Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 12 May 2026 23:17:53 -0700 Subject: [PATCH 7/7] Prevent view-transition state leak via scroll restoration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge in getScrollState forwarded every field of the current location.state into the next navigation — including the view-transition {transition, applyTo} fields stored by LinkWithViewTransition. That caused offer/discover navigations from /gift-cards (raw navigate, no transition) to inherit the home→/gift-cards slide direction, producing wrong-direction slide animations after a hard reset. Strip the view-transition fields from the merge so only caller-owned state (e.g. sibling scroll positions) is forwarded. --- .../gift-cards/use-restore-scroll-position.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/app/features/gift-cards/use-restore-scroll-position.ts b/app/features/gift-cards/use-restore-scroll-position.ts index 58439c2d0..e64e07417 100644 --- a/app/features/gift-cards/use-restore-scroll-position.ts +++ b/app/features/gift-cards/use-restore-scroll-position.ts @@ -4,9 +4,9 @@ import { z } from 'zod'; /** * Persists and restores the horizontal scroll position of a container across - * navigations, keyed by `stateKey` in `location.state`. Pass `getScrollState()` - * as the `state` argument to `navigate(...)` when leaving the page so the - * position gets restored on back navigation. + * 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(); @@ -15,11 +15,11 @@ export function useRestoreScrollPosition(stateKey: K) { useEffect(() => { const result = z - .object({ [stateKey]: z.number() }) + .object({ scrollPositions: z.object({ [stateKey]: z.number() }) }) .safeParse(location.state); if (result.success && scrollRef.current) { - scrollRef.current.scrollLeft = result.data[stateKey]; + scrollRef.current.scrollLeft = result.data.scrollPositions[stateKey]; } }, [location.state, stateKey]); @@ -29,12 +29,17 @@ export function useRestoreScrollPosition(stateKey: K) { } }; - const getScrollState = () => ({ - ...(typeof location.state === 'object' && location.state !== null - ? location.state - : {}), - [stateKey]: scrollPositionRef.current, - }); + 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 }; }