Skip to content

Commit 1ce5893

Browse files
authored
feat(treasury): NFT preview with full-grid dialog, and paged inflows (#263)
* wip(treasury): NFT preview + dialog, inflows list split out Work in progress, paused to investigate a /stake graph regression. * feat(treasury): page the inflows list, 20 at a time The panel fetched eight rows and rendered them on the server. Inflows are newest-first and the auction credits sit further back, so the Auction badge fired correctly and was never once on screen. The rows move to a client component so "show more" can hold state, and the server hands down the whole window instead of a slice. Verified: the Auction badge is now visible on first paint.
1 parent ea9e7e4 commit 1ce5893

5 files changed

Lines changed: 240 additions & 112 deletions

File tree

messages/en/treasury.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
},
7070
"inflows": {
7171
"title": "Latest inflows",
72+
"showMore": "Show {count} more",
7273
"empty": "No recent inflows.",
7374
"auction": "Auction",
7475
"auctionHint": "Arrived from the auction house as an internal transfer",

messages/pt-br/treasury.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
},
7070
"inflows": {
7171
"title": "Últimas entradas",
72+
"showMore": "Ver mais {count}",
7273
"empty": "Nenhuma entrada recente.",
7374
"auction": "Leilão",
7475
"auctionHint": "Chegou do contrato de leilão como transferência interna",

src/components/treasury/NftHoldings.tsx

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,28 @@ import { useLocale, useTranslations } from "next-intl";
55
import { GnarCard } from "@/components/auctions/GnarCard";
66
import { LoadingGridSkeleton } from "@/components/skeletons/loading-grid-skeleton";
77
import { Card, CardContent } from "@/components/ui/card";
8+
import {
9+
Dialog,
10+
DialogContent,
11+
DialogDescription,
12+
DialogHeader,
13+
DialogTitle,
14+
DialogTrigger,
15+
} from "@/components/ui/dialog";
816
import { DAO_ADDRESSES } from "@/lib/config";
917
import { toIntlLocale } from "@/lib/i18n/format";
1018
import { subgraphQuery } from "@/lib/subgraph";
1119

20+
/**
21+
* Tiles shown on the card before the dialog takes over. Seven plus the "see the
22+
* rest" cell fills exactly two rows of the four-column grid, which is what the
23+
* design shows — and what keeps this card a similar height to the Token
24+
* Holdings card beside it. Rendering all of them inline is what made the page
25+
* scroll for screens on end and left a tall column of page background next to a
26+
* short neighbour.
27+
*/
28+
const PREVIEW_COUNT = 7;
29+
1230
interface NftHoldingsProps {
1331
treasuryAddress: string;
1432
}
@@ -208,6 +226,8 @@ export function NftHoldings({ treasuryAddress }: NftHoldingsProps) {
208226
);
209227
}
210228

229+
const remaining = tokens.length - PREVIEW_COUNT;
230+
211231
return (
212232
<div className="space-y-4">
213233
<div className="flex justify-between items-center">
@@ -219,19 +239,68 @@ export function NftHoldings({ treasuryAddress }: NftHoldingsProps) {
219239
</div>
220240
</div>
221241

222-
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
223-
{tokens.slice(0, visibleCount).map((token) => (
224-
<GnarCard
225-
key={`gnar-${token.id}`}
226-
tokenId={token.id}
227-
imageUrl={token.imageUrl}
228-
dateLabel={token.dateLabel}
229-
finalBidEth={token.finalBidEth ?? null}
230-
winnerAddress={token.winnerAddress ?? null}
231-
/>
242+
{/* Preview: plain image tiles, not GnarCards. The card sits in a narrow
243+
column, and the full card's date/bid/winner rows would be unreadable at
244+
this width — the detail belongs in the dialog, where there is room. */}
245+
<div className="grid grid-cols-4 gap-2.5">
246+
{tokens.slice(0, PREVIEW_COUNT).map((token) => (
247+
<div
248+
key={`preview-${token.id}`}
249+
title={`Gnar #${token.id}`}
250+
className="aspect-square overflow-hidden rounded-lg bg-muted"
251+
>
252+
{token.imageUrl ? (
253+
// eslint-disable-next-line @next/next/no-img-element -- subgraph URLs are unbounded hosts; next/image would need every one allow-listed
254+
<img
255+
src={token.imageUrl}
256+
alt={`Gnar #${token.id}`}
257+
loading="lazy"
258+
className="size-full object-cover"
259+
/>
260+
) : null}
261+
</div>
232262
))}
263+
264+
{remaining > 0 ? (
265+
<Dialog>
266+
<DialogTrigger asChild>
267+
{/* The count is the point: "+571" and "+4" are different
268+
decisions, and a bare "see more" hides which one this is. */}
269+
<button
270+
type="button"
271+
className="flex aspect-square cursor-pointer items-center justify-center rounded-lg border border-border bg-muted/50 font-mono text-xs font-medium text-muted-foreground transition-colors hover:bg-muted"
272+
>
273+
+{remaining}
274+
</button>
275+
</DialogTrigger>
276+
{/* Same scrollbar treatment as GnarsStakeDialog: without it the long
277+
grid brings back the platform's default wide grey track running
278+
down the inside of a rounded panel. */}
279+
<DialogContent className="max-h-[88vh] w-[calc(100%-2rem)] overflow-y-auto sm:max-w-[900px] [scrollbar-width:thin] [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-foreground/20 [&::-webkit-scrollbar-thumb:hover]:bg-foreground/30 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-1.5">
280+
<DialogHeader>
281+
<DialogTitle>{t("nfts.title")}</DialogTitle>
282+
<DialogDescription>{t("nfts.count", { count: tokens.length })}</DialogDescription>
283+
</DialogHeader>
284+
285+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
286+
{tokens.slice(0, visibleCount).map((token) => (
287+
<GnarCard
288+
key={`gnar-${token.id}`}
289+
tokenId={token.id}
290+
imageUrl={token.imageUrl}
291+
dateLabel={token.dateLabel}
292+
finalBidEth={token.finalBidEth ?? null}
293+
winnerAddress={token.winnerAddress ?? null}
294+
/>
295+
))}
296+
</div>
297+
{/* Paging moved in here with the grid, so the sentinel scrolls
298+
inside the dialog instead of extending the page. */}
299+
{visibleCount < tokens.length ? <div ref={sentinelRef} className="h-10" /> : null}
300+
</DialogContent>
301+
</Dialog>
302+
) : null}
233303
</div>
234-
{visibleCount < tokens.length && <div ref={sentinelRef} className="h-10" />}
235304
</div>
236305
);
237306
}

src/components/treasury/TreasuryInflows.tsx

Lines changed: 18 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,29 @@
11
import { getTranslations } from "next-intl/server";
2-
import { ArrowDownLeft, ExternalLink, Gavel } from "lucide-react";
3-
import { AddressDisplay } from "@/components/ui/address-display";
2+
import { ArrowDownLeft } from "lucide-react";
3+
import { TreasuryInflowsList } from "@/components/treasury/TreasuryInflowsList";
44
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5-
import {
6-
loadTreasuryInflows,
7-
type InflowAsset,
8-
type InflowSource,
9-
} from "@/services/treasury-inflows";
10-
11-
/**
12-
* Source accents. Not semantic tokens on purpose — like the asset colours below,
13-
* these identify a kind of income, and the point is telling them apart at a
14-
* glance rather than following the theme's foreground.
15-
*/
16-
const SOURCE_TONE: Record<InflowSource, string> = {
17-
auction: "border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-400",
18-
subnet: "border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
19-
splits: "border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
20-
transfer: "border-border bg-muted text-muted-foreground",
21-
};
22-
23-
/** Per-asset accent. Deliberately not the semantic tokens — these identify a currency. */
24-
const ASSET_TONE: Record<InflowAsset, string> = {
25-
ETH: "text-[#627eea]",
26-
WETH: "text-[#627eea]",
27-
USDC: "text-[#2775ca]",
28-
};
29-
30-
/**
31-
* ETH is worth ~4 decimals; USDC is a dollar figure and reads wrong with more
32-
* than two. `maximumFractionDigits` alone would print `0` for auction dust, so
33-
* very small ETH amounts keep enough significant digits to stay non-zero.
34-
*/
35-
function formatAmount(amount: number, asset: InflowAsset, locale: string): string {
36-
if (asset === "USDC") {
37-
return amount.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
38-
}
39-
if (amount > 0 && amount < 0.0001) {
40-
return amount.toLocaleString(locale, { maximumSignificantDigits: 2 });
41-
}
42-
return amount.toLocaleString(locale, { maximumFractionDigits: 4 });
43-
}
44-
45-
function ageLabel(at: string, now: number): string {
46-
const ms = now - new Date(at).getTime();
47-
if (!Number.isFinite(ms) || ms < 0) return "";
48-
const mins = Math.floor(ms / 60_000);
49-
if (mins < 60) return `${Math.max(1, mins)}m`;
50-
const hours = Math.floor(mins / 60);
51-
if (hours < 24) return `${hours}h`;
52-
const days = Math.floor(hours / 24);
53-
if (days < 30) return `${days}d`;
54-
return `${Math.floor(days / 30)}mo`;
55-
}
5+
import { loadTreasuryInflows } from "@/services/treasury-inflows";
566

577
/**
588
* The treasury's most recent income, newest first.
599
*
60-
* Auction settlements arrive as internal transfers and are marked as such — it
61-
* is the difference between "someone sent us money" and "the DAO earned it".
10+
* This component is now only the frame and the fetch — the rows moved to
11+
* `TreasuryInflowsList`, a client component, because "show more" needs state.
12+
*
13+
* The whole window is fetched here and paged in the browser rather than sliced
14+
* on the server, and the ordering is why: inflows are newest-first, and the
15+
* auction credits are older than the newest handful. Fetching eight rows meant
16+
* the Auction badge existed in code, fired correctly, and was never once on
17+
* screen. Paging client-side costs a slightly larger payload and shows the
18+
* history that makes the badges worth having.
6219
*/
20+
const WINDOW = 100;
21+
6322
export async function TreasuryInflows({ locale }: { locale: string }) {
6423
const t = await getTranslations("treasury.inflows");
65-
const inflows = await loadTreasuryInflows(8);
66-
// Rendered once per request on the server; the timestamp IS the snapshot.
24+
const inflows = await loadTreasuryInflows(WINDOW);
25+
// Rendered once per request on the server; the timestamp IS the snapshot, so
26+
// the relative ages cannot shift between server render and hydration.
6727
// eslint-disable-next-line react-hooks/purity -- server component, render-time clock read for relative ages
6828
const now = Date.now();
6929

@@ -79,50 +39,7 @@ export async function TreasuryInflows({ locale }: { locale: string }) {
7939
{inflows.length === 0 ? (
8040
<p className="py-6 text-center text-sm text-muted-foreground">{t("empty")}</p>
8141
) : (
82-
<ul className="divide-y divide-border">
83-
{inflows.map((flow) => (
84-
<li key={flow.hash} className="flex items-center gap-3 py-2.5">
85-
<div className="flex min-w-0 flex-1 items-center gap-2">
86-
<AddressDisplay
87-
address={flow.from}
88-
variant="compact"
89-
showCopy={false}
90-
showExplorer={false}
91-
truncateLength={4}
92-
/>
93-
{/* Every row carries its origin now, not just auctions. The
94-
binary "internal = auction" badge could not say where the
95-
other three quarters of the income came from. */}
96-
<span
97-
className={`inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${SOURCE_TONE[flow.source]}`}
98-
title={flow.source === "auction" ? t("auctionHint") : undefined}
99-
>
100-
{flow.source === "auction" ? <Gavel className="size-3" /> : null}
101-
{t(flow.source)}
102-
</span>
103-
</div>
104-
105-
<span className="shrink-0 whitespace-nowrap font-mono text-sm font-semibold tabular-nums">
106-
+{formatAmount(flow.amount, flow.asset, locale)}{" "}
107-
<span className={ASSET_TONE[flow.asset]}>{flow.asset}</span>
108-
</span>
109-
110-
<span className="w-9 shrink-0 text-right font-mono text-[11px] text-muted-foreground">
111-
{ageLabel(flow.at, now)}
112-
</span>
113-
114-
<a
115-
href={`https://basescan.org/tx/${flow.hash}`}
116-
target="_blank"
117-
rel="noopener noreferrer"
118-
aria-label={t("viewTx")}
119-
className="shrink-0 text-muted-foreground hover:text-foreground"
120-
>
121-
<ExternalLink className="size-3.5" />
122-
</a>
123-
</li>
124-
))}
125-
</ul>
42+
<TreasuryInflowsList inflows={inflows} locale={locale} now={now} />
12643
)}
12744
</CardContent>
12845
</Card>

0 commit comments

Comments
 (0)