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
2 changes: 1 addition & 1 deletion app/features/accounts/account-icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function AccountIcon({
type,
purpose,
}: { type: AccountType; purpose: AccountPurpose }) {
if (purpose === 'gift-card') {
if (purpose === 'gift-card' || purpose === 'offer') {
return <GiftCardIcon />;
}
return iconsByAccountType[type];
Expand Down
58 changes: 9 additions & 49 deletions app/features/gift-cards/discover-gift-cards.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,16 @@
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,
} from '~/components/wallet-card';
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<HTMLDivElement>(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<number>;
getScrollState: () => object;
children: React.ReactNode;
};

Expand All @@ -56,7 +20,7 @@ type DiscoverCardLinkProps = {
*/
function DiscoverCardLink({
card,
scrollPositionRef,
getScrollState,
children,
}: DiscoverCardLinkProps) {
const navigate = useNavigate();
Expand All @@ -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<typeof DiscoverCardsLocationStateSchema>,
});
navigate(to, { viewTransition: true, state: getScrollState() });
};

return (
Expand Down Expand Up @@ -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 (
<div
Expand All @@ -128,7 +88,7 @@ export function DiscoverGiftCards({ giftCards }: DiscoverSectionProps) {
<DiscoverCardLink
key={`${card.url}:${card.currency}`}
card={card}
scrollPositionRef={scrollPositionRef}
getScrollState={getScrollState}
>
<WalletCard
size="sm"
Expand Down
43 changes: 4 additions & 39 deletions app/features/gift-cards/gift-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { DiscoverGiftCards } from './discover-gift-cards';
import { EmptyState } from './empty-state';
import { getGiftCardImageByUrl } from './gift-card-images';
import { GiftCardItem } from './gift-card-item';
import { OfferItem } from './offer-item';
import { OffersSection } from './offers-section';
import { useDiscoverGiftCards } from './use-discover-cards';

/**
Expand All @@ -34,9 +34,6 @@ export function GiftCards() {
const isGiftCardTransitioning = useViewTransitionState(
'/gift-cards/:accountId',
);
const isOfferCardTransitioning = useViewTransitionState(
'/gift-cards/offers/:accountId',
);

const hasCards = accounts.length > 0;
const stackedHeight =
Expand All @@ -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 (
<Page className="px-0 pb-0">
<PageHeader className="absolute inset-x-0 top-0 z-20 mb-0 px-4 pt-4 pb-4">
<PageHeader className="px-4">
<ClosePageButton to="/" transition="slideLeft" applyTo="oldView" />
<PageHeaderTitle>Gift Cards</PageHeaderTitle>
</PageHeader>

<PageContent className="scrollbar-none relative min-h-0 overflow-y-auto px-0 pt-16 pb-0">
<PageContent className="scrollbar-none relative min-h-0 overflow-y-auto px-0 pb-0">
<div className="flex w-full flex-col items-center gap-4">
{giftCardsToDiscover.length > 0 && (
<DiscoverGiftCards giftCards={giftCardsToDiscover} />
)}

{offerCards.length > 0 && (
<div className="flex w-full shrink-0 flex-col items-center px-4 pb-8">
<h2 className="mb-3 w-full text-white">Offers</h2>
<div
className="flex w-full flex-col gap-3"
style={{ maxWidth: CARD_WIDTH }}
>
{offerCards.map((account) => (
<button
key={account.id}
type="button"
onClick={() => handleOfferClick(account)}
aria-label={`View ${account.name} offer`}
className="w-full"
style={{
viewTransitionName: isOfferCardTransitioning
? `offer-${account.id}`
: undefined,
}}
>
<OfferItem
account={account}
image={getGiftCardImageByUrl(account.mintUrl)}
/>
</button>
))}
</div>
</div>
)}
<OffersSection offers={offerCards} />

{hasCards ? (
<div className="flex w-full shrink-0 flex-col items-center pb-8">
Expand Down
156 changes: 156 additions & 0 deletions app/features/gift-cards/offers-section.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={onClick}
aria-label={`View ${account.name} offer`}
className={cn('block', className)}
style={{
viewTransitionName: isTransitioning ? `offer-${account.id}` : undefined,
}}
>
<WalletCard
size={size}
className={cn(size === 'default' && 'w-full max-w-none', cardClassName)}
>
{image ? (
<WalletCardBackgroundImage src={image} alt={account.name} />
) : (
<>
<WalletCardBlank />
<WalletCardOverlay className="flex items-center justify-center px-4">
<span className="truncate text-card-foreground text-lg">
{account.name}
</span>
</WalletCardOverlay>
</>
)}
</WalletCard>
</button>
);
}

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 (
<div className="flex w-full shrink-0 flex-col items-center px-4 pb-8">
<h2 className="mb-3 w-full text-white">Offers</h2>
<div className="w-full" style={{ maxWidth: CARD_WIDTH }}>
<OfferCardButton
account={offer}
isTransitioning={isTransitioning}
onClick={() => navigateToOffer(offer)}
className="w-full"
/>
</div>
</div>
);
}

if (offers.length === 2) {
return (
<div className="flex w-full shrink-0 flex-col items-center px-4 pb-8">
<h2 className="mb-3 w-full text-white">Offers</h2>
<div className="flex w-full gap-3" style={{ maxWidth: CARD_WIDTH }}>
{offers.map((offer) => (
<OfferCardButton
key={offer.id}
account={offer}
isTransitioning={isTransitioning}
onClick={() => navigateToOffer(offer)}
className="min-w-0 flex-1"
/>
))}
</div>
</div>
);
}

return (
<div className="w-full shrink-0 pb-8">
<h2 className="mb-3 px-4 text-white">Offers</h2>
<div className="sm:px-4">
<div
ref={scrollRef}
onScroll={handleScroll}
className={cn(
'overflow-x-auto pb-1',
isMobile
? 'scrollbar-none'
: '[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:h-1.5',
)}
>
<div className="flex w-max gap-3 pb-2">
{offers.map((offer, index) => (
<OfferCardButton
key={offer.id}
account={offer}
size="sm"
isTransitioning={isTransitioning}
onClick={() => navigateToOffer(offer)}
cardClassName={cn(
index === 0 && 'ml-4 sm:ml-0',
index === offers.length - 1 && 'mr-4 sm:mr-0',
)}
/>
))}
</div>
</div>
</div>
</div>
);
}
45 changes: 45 additions & 0 deletions app/features/gift-cards/use-restore-scroll-position.ts
Original file line number Diff line number Diff line change
@@ -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<K extends string>(stateKey: K) {
const location = useLocation();
const scrollRef = useRef<HTMLDivElement>(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 };
}
Loading
Loading