perf(marketplace): lazy-mount grid cards + open the detail page on click
- LazyMount: mount a grid card only when it scrolls within 600px (skeleton-space placeholder otherwise), so a long feed no longer mounts every card + its hooks/DOM up front. Falls back to immediate render without IntersectionObserver. - GearSwapItemCard: clicking the card now opens /listing/:pubkey/:identifier (was opening a DM). 'Contact Seller' still opens the DM. Card set to h-full so the lazy wrapper keeps the equal-height grid. Gate: 101 tests, 0 errors/warnings, boot smoke green.
This commit is contained in:
parent
269d5d6c09
commit
662a14bcd2
3 changed files with 71 additions and 9 deletions
|
|
@ -127,12 +127,12 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
|
||||
return (
|
||||
<Card
|
||||
className={`bg-black/90 border-2 group cursor-pointer transition-all hover:scale-[1.02] ${
|
||||
className={`bg-black/90 border-2 group cursor-pointer transition-all hover:scale-[1.02] h-full ${
|
||||
isWTB
|
||||
? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]'
|
||||
: 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-[0_0_30px_rgba(255,102,0,0.2)]'
|
||||
}`}
|
||||
onClick={() => navigate(`/messages/${npub}`)}
|
||||
onClick={() => navigate(`/listing/${item.pubkey}/${item.identifier}`)}
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
{/* Image */}
|
||||
|
|
|
|||
60
src/components/LazyMount.tsx
Normal file
60
src/components/LazyMount.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Defer mounting `children` until the wrapper scrolls near the viewport.
|
||||
*
|
||||
* Long grids (e.g. the marketplace) otherwise mount every card — and every
|
||||
* hook/query/DOM node each one pulls in — up front. With this, off-screen cards
|
||||
* stay as a cheap placeholder until they're ~600px away, then mount once and
|
||||
* stay mounted. Falls back to immediate render when IntersectionObserver is
|
||||
* unavailable.
|
||||
*/
|
||||
export function LazyMount({
|
||||
children,
|
||||
minHeight = 300,
|
||||
rootMargin = '600px',
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
/** Reserve height so the page doesn't jump when the card mounts. */
|
||||
minHeight?: number;
|
||||
rootMargin?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [visible, rootMargin]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={visible ? undefined : { minHeight }}
|
||||
aria-busy={!visible || undefined}
|
||||
>
|
||||
{visible ? children : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMe
|
|||
import { useMarketplaceItems } from '@/hooks/useMarketplace';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||
import { LazyMount } from '@/components/LazyMount';
|
||||
import { useBatchSellerSales } from '@/hooks/useSellerSales';
|
||||
import { useFollowList } from '@/hooks/useFollowList';
|
||||
import { GearSwapItemCard } from '@/components/GearSwapItemCard';
|
||||
|
|
@ -410,13 +411,14 @@ export default function GearSwapPage() {
|
|||
{!isLoading && !error && filteredItems.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filteredItems.map((item) => (
|
||||
<GearSwapItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
trustScore={trustScores?.get(item.pubkey)}
|
||||
salesCount={sellerSales?.get(item.pubkey) ?? 0}
|
||||
isFollowing={followingSet.has(item.pubkey)}
|
||||
/>
|
||||
<LazyMount key={item.id} className="h-full">
|
||||
<GearSwapItemCard
|
||||
item={item}
|
||||
trustScore={trustScores?.get(item.pubkey)}
|
||||
salesCount={sellerSales?.get(item.pubkey) ?? 0}
|
||||
isFollowing={followingSet.has(item.pubkey)}
|
||||
/>
|
||||
</LazyMount>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue