Skip to content

Commit 713457e

Browse files
sktbrdclaude
andcommitted
feat(base): auction demo mode — replay settled auctions with crew commentary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7b8a963 commit 713457e

5 files changed

Lines changed: 201 additions & 6 deletions

File tree

messages/en/base.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,19 @@
4040
},
4141
"culture": "What Gnars brings to Base is an audience crypto doesn't reach on its own: skate, BMX, and surf culture, funding real riders and real videos through a DAO that actually executes onchain.",
4242
"liveTitle": "Don't take our word for it — this is the live product",
43-
"liveDesc": "The sections below aren't screenshots. They're the same live components the site runs in production — auction, governance, swap, and rider staking."
43+
"liveDesc": "The sections below aren't screenshots. They're the same live components the site runs in production — auction, governance, swap, and rider staking.",
44+
"demo": {
45+
"chip": "Demo — replaying settled auctions",
46+
"sold": "Sold",
47+
"wonBy": "won by",
48+
"gnarNo": "Gnar #{id}",
49+
"totalRaised": "{n} ETH raised by the daily auction so far",
50+
"quips": {
51+
"q1": "Sold. Straight into the treasury, no middleman.",
52+
"q2": "That's another video part funded.",
53+
"q3": "One Gnar a day keeps the sponsors away.",
54+
"q4": "Grip tape money for the whole crew.",
55+
"q5": "Bid, win, govern. That's the loop."
56+
}
57+
}
4458
}

messages/pt-br/base.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,19 @@
4040
},
4141
"culture": "O que a Gnars traz pra Base é uma audiência que a cripto não alcança sozinha: a cultura do skate, BMX e surf, financiando riders e vídeos de verdade através de uma DAO que executa onchain de fato.",
4242
"liveTitle": "Não precisa acreditar — isto abaixo é o produto ao vivo",
43-
"liveDesc": "As seções abaixo não são screenshots. São os mesmos componentes que o site roda em produção — leilão, governança, swap e staking de riders."
43+
"liveDesc": "As seções abaixo não são screenshots. São os mesmos componentes que o site roda em produção — leilão, governança, swap e staking de riders.",
44+
"demo": {
45+
"chip": "Demo — replay de leilões encerrados",
46+
"sold": "Vendido",
47+
"wonBy": "arrematado por",
48+
"gnarNo": "Gnar #{id}",
49+
"totalRaised": "{n} ETH levantados pelo leilão diário até agora",
50+
"quips": {
51+
"q1": "Vendido. Direto pro tesouro, sem intermediário.",
52+
"q2": "Mais uma video part financiada.",
53+
"q3": "Um Gnar por dia espanta os patrocinadores chatos.",
54+
"q4": "Dinheiro de lixa pra crew inteira.",
55+
"q5": "Dá lance, ganha, governa. Esse é o loop."
56+
}
57+
}
4458
}

src/app/[locale]/base/page.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import type { Metadata } from "next";
22
import { getTranslations, setRequestLocale } from "next-intl/server";
3+
import { AuctionDemoPanel } from "@/components/base/AuctionDemoPanel";
34
import { BasePageContent, BaseUnderHood } from "@/components/base/BasePageContent";
45
import { DroposalsGrid } from "@/components/droposals/DroposalsGrid";
56
import { GovSection } from "@/components/newhome/GovSection";
67
import { StakeSection } from "@/components/newhome/StakeSection";
78
import { SwapSection } from "@/components/newhome/SwapSection";
9+
import { DAO_ADDRESSES } from "@/lib/config";
10+
import { fetchRecentAuctions } from "@/services/auctions";
811
import { fetchDroposals } from "@/services/droposals";
12+
import { loadTreasurySnapshot } from "@/services/treasury";
913

1014
// The pitch page for Base ecosystem programs (Base Batches): everything Gnars
1115
// runs on Base, with links to the live surfaces. See BasePageContent.
@@ -50,15 +54,26 @@ export default async function BasePage({ params }: { params: Promise<{ locale: s
5054
const t = await getTranslations({ locale, namespace: "base" });
5155

5256
// The freshest four drops are enough for the pitch; /droposals has them all.
53-
const droposals = await fetchDroposals(4).catch(() => []);
57+
// The auction card runs in DEMO mode: real settled auctions replayed, plus the
58+
// real cumulative auction revenue from the treasury snapshot — a fresh live
59+
// auction at 0.000 ETH sells nothing.
60+
const [droposals, pastAuctions, treasury] = await Promise.all([
61+
fetchDroposals(4).catch(() => []),
62+
fetchRecentAuctions(8).catch(() => []),
63+
loadTreasurySnapshot(DAO_ADDRESSES.treasury).catch(() => ({ totalAuctionSales: 0 })),
64+
]);
5465

5566
return (
5667
<div className="py-10">
5768
<BasePageContent />
5869
{/* The proof: live production sections, not screenshots. GovSection is
5970
the daily auction + recent proposals + activity feed; SwapSection is
6071
the working 0x swap widget; StakeSection is the rider roster. */}
61-
<GovSection />
72+
<GovSection
73+
auction={
74+
<AuctionDemoPanel items={pastAuctions} totalSalesEth={treasury.totalAuctionSales} />
75+
}
76+
/>
6277
{droposals.length > 0 && (
6378
<section className="mx-auto w-full max-w-5xl px-4 pb-4 pt-10 sm:px-6">
6479
<h2 className="text-2xl font-extrabold tracking-tight sm:text-3xl">
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"use client";
2+
3+
// Demo mode for the auction card on /base. The live AuctionPanel can be an
4+
// awkward salesman — a fresh auction reads "0.000 ETH" until the first bid.
5+
// This panel REPLAYS real settled auctions instead: real Gnars, real final
6+
// bids, real winners, cycling with a count-up and a crew quip. Everything
7+
// on-screen happened; the only cosmetic layer is the commentary, and the DEMO
8+
// chip says exactly what the card is. The footer states the real cumulative
9+
// auction revenue (same figure the treasury page reports).
10+
import { useEffect, useMemo, useState } from "react";
11+
import { useTranslations } from "next-intl";
12+
import { AnimatePresence, motion } from "framer-motion";
13+
import { CHARACTERS } from "@/components/stake/CharacterSelector";
14+
import { CountUp } from "@/components/ui/count-up";
15+
import { Link } from "@/i18n/navigation";
16+
import { cn } from "@/lib/utils";
17+
import type { PastAuction } from "@/services/auctions";
18+
19+
const GOLD = "linear-gradient(135deg,#f7c948,#f5851f)";
20+
const ROTATE_MS = 6000;
21+
const QUIPS = ["q1", "q2", "q3", "q4", "q5"] as const;
22+
// Quip narrators cycle through the same crew that fronts /morpheus.
23+
const NARRATORS = ["vlad", "r4to", "yan", "pamtech"] as const;
24+
25+
export function AuctionDemoPanel({
26+
items,
27+
totalSalesEth,
28+
}: {
29+
items: PastAuction[];
30+
totalSalesEth: number;
31+
}) {
32+
const t = useTranslations("base.demo");
33+
const [index, setIndex] = useState(0);
34+
35+
const usable = useMemo(() => items.filter((a) => a.imageUrl && Number(a.finalBid) > 0), [items]);
36+
37+
useEffect(() => {
38+
if (usable.length < 2) return;
39+
const id = setInterval(() => setIndex((i) => (i + 1) % usable.length), ROTATE_MS);
40+
return () => clearInterval(id);
41+
}, [usable.length]);
42+
43+
if (usable.length === 0) return null;
44+
const a = usable[index % usable.length];
45+
const narrator = CHARACTERS.find((c) => c.id === NARRATORS[index % NARRATORS.length]);
46+
47+
return (
48+
<div className="flex h-full flex-col gap-4 rounded-2xl border border-[#f7c948]/25 bg-card p-5">
49+
<div className="flex items-center justify-between gap-3">
50+
<span
51+
className="inline-flex items-center gap-[7px] rounded-full px-3 py-1 text-[11px] font-extrabold uppercase tracking-[0.08em]"
52+
style={{ backgroundImage: GOLD, color: "#1a1205" }}
53+
>
54+
{t("chip")}
55+
</span>
56+
</div>
57+
58+
<AnimatePresence mode="wait">
59+
<motion.div
60+
key={a.id}
61+
initial={{ opacity: 0, x: 24 }}
62+
animate={{ opacity: 1, x: 0 }}
63+
exit={{ opacity: 0, x: -24 }}
64+
transition={{ duration: 0.35 }}
65+
className="flex flex-1 flex-col gap-4"
66+
>
67+
<div className="relative aspect-square w-full overflow-hidden rounded-xl border border-border/60">
68+
{a.imageUrl ? (
69+
// IPFS gateway art, unknown dimensions; plain img matches PastAuctions.
70+
// eslint-disable-next-line @next/next/no-img-element
71+
<img
72+
src={a.imageUrl}
73+
alt={t("gnarNo", { id: a.tokenId })}
74+
className="h-full w-full object-cover"
75+
/>
76+
) : null}
77+
<span className="absolute left-3 top-3 rounded-full bg-black/70 px-2.5 py-1 font-mono text-xs font-bold text-white">
78+
{t("gnarNo", { id: a.tokenId })}
79+
</span>
80+
<span
81+
className="absolute right-3 top-3 rounded-full px-2.5 py-1 text-[11px] font-extrabold uppercase tracking-wider"
82+
style={{ backgroundImage: GOLD, color: "#1a1205" }}
83+
>
84+
{t("sold")}
85+
</span>
86+
</div>
87+
88+
<div className="flex items-baseline justify-between gap-3">
89+
<span className="font-mono text-2xl font-black tabular-nums">
90+
<CountUp value={Number(a.finalBid)} decimals={3} durationMs={900} /> ETH
91+
</span>
92+
<span className="truncate text-xs text-muted-foreground">
93+
{t("wonBy")}{" "}
94+
<Link
95+
href={`/members/${a.winner}`}
96+
className="font-mono font-semibold text-foreground hover:underline"
97+
>
98+
{a.winner.slice(0, 6)}{a.winner.slice(-4)}
99+
</Link>
100+
</span>
101+
</div>
102+
103+
{narrator ? (
104+
<div className="flex items-center gap-2.5">
105+
<div
106+
aria-hidden
107+
className="h-10 w-10 shrink-0"
108+
style={{
109+
backgroundImage: `url("${narrator.image}")`,
110+
backgroundSize: narrator.face.size,
111+
backgroundPosition: narrator.face.pos,
112+
backgroundRepeat: "no-repeat",
113+
maskImage: "linear-gradient(to bottom, black 72%, transparent 98%)",
114+
WebkitMaskImage: "linear-gradient(to bottom, black 72%, transparent 98%)",
115+
}}
116+
/>
117+
<motion.p
118+
key={`${a.id}-quip`}
119+
initial={{ opacity: 0, y: 4 }}
120+
animate={{ opacity: 1, y: 0 }}
121+
transition={{ delay: 0.4 }}
122+
className="rounded-2xl border border-border/60 bg-background/40 px-3 py-2 text-xs leading-relaxed text-muted-foreground"
123+
>
124+
{t(`quips.${QUIPS[index % QUIPS.length]}`)}
125+
</motion.p>
126+
</div>
127+
) : null}
128+
</motion.div>
129+
</AnimatePresence>
130+
131+
{/* Rotation dots — real state, one per replayed auction. */}
132+
<div className="flex items-center justify-center gap-1.5">
133+
{usable.map((x, i) => (
134+
<button
135+
key={x.id}
136+
type="button"
137+
aria-label={t("gnarNo", { id: x.tokenId })}
138+
onClick={() => setIndex(i)}
139+
className={cn(
140+
"h-1.5 rounded-full transition-all",
141+
i === index % usable.length ? "w-5 bg-foreground/80" : "w-1.5 bg-foreground/25",
142+
)}
143+
/>
144+
))}
145+
</div>
146+
147+
<p className="border-t border-border/60 pt-3 text-center text-xs font-semibold">
148+
{t("totalRaised", { n: Math.round(totalSalesEth) })}
149+
</p>
150+
</div>
151+
);
152+
}

src/components/newhome/GovSection.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ async function Feed() {
4242
* independently-chromed cards read as three widgets parked next to each other.
4343
* The data and the auction's write path are still the production ones.
4444
*/
45-
export async function GovSection() {
45+
export async function GovSection({ auction }: { auction?: React.ReactNode } = {}) {
4646
const t = await getTranslations("newhome.gov");
4747

4848
return (
@@ -56,7 +56,7 @@ export async function GovSection() {
5656
</div>
5757

5858
<div className="grid grid-cols-1 items-stretch gap-5 lg:grid-cols-2">
59-
<AuctionPanel />
59+
{auction ?? <AuctionPanel />}
6060

6161
<div className="flex h-full min-w-0 flex-col gap-4 rounded-2xl border border-border bg-card p-5">
6262
<Suspense fallback={<RecentProposalsSkeleton />}>

0 commit comments

Comments
 (0)