Make profile stat pills clickable — jump to their section

- StatPill now accepts an onClick and renders as a hover-lift button when it has
  a non-zero value (inert div when empty), with clear affordance
- Spots / Shops / Crews / Gear pills smooth-scroll to their matching section
  (added ids + scroll-mt-24 offset so headings aren't hidden under sticky nav)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-02 22:23:22 +07:00
parent 8ba54273b7
commit 9958020776

View file

@ -60,23 +60,26 @@ function profileEventCreatedAt(createdAt: number | undefined): string | undefine
return new Date(createdAt * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'short' }); return new Date(createdAt * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'short' });
} }
/** A compact stat pill for the at-a-glance strip. */ /** A compact stat pill for the at-a-glance strip. Clickable when `onClick` is
* provided (jumps to the matching section); shows disabled/inert when empty. */
function StatPill({ function StatPill({
value, value,
label, label,
color, color,
loading, loading,
icon, icon,
onClick,
}: { }: {
value: number; value: number;
label: string; label: string;
color: string; color: string;
loading?: boolean; loading?: boolean;
icon?: ReactNode; icon?: ReactNode;
onClick?: () => void;
}) { }) {
return ( const interactive = !!onClick && value > 0;
<div className="flex items-center gap-2.5 rounded-xl border border-white/10 bg-white/5 px-3.5 py-2.5 min-w-[84px]"> const inner = (
<div className="leading-none"> <div className="leading-none text-left">
{loading ? ( {loading ? (
<Skeleton className="h-6 w-8" /> <Skeleton className="h-6 w-8" />
) : ( ) : (
@ -85,10 +88,27 @@ function StatPill({
{value} {value}
</p> </p>
)} )}
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold mt-0.5">{label}</p> <p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold mt-0.5 group-hover:text-gray-300 transition-colors">
</div> {label}
</p>
</div> </div>
); );
const base = 'flex items-center gap-2.5 rounded-xl border px-3.5 py-2.5 min-w-[84px] transition-all';
if (interactive) {
return (
<button
type="button"
onClick={onClick}
className={`group ${base} border-white/10 bg-white/5 hover:bg-white/10 hover:border-white/25 hover:-translate-y-0.5 cursor-pointer`}
>
{inner}
</button>
);
}
return <div className={`group ${base} border-white/10 bg-white/5`}>{inner}</div>;
} }
function ProfilePage({ pubkey }: { pubkey: string }) { function ProfilePage({ pubkey }: { pubkey: string }) {
@ -150,6 +170,12 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
// Smooth-scroll a stat pill to its matching section on the page.
const scrollToSection = (id: string) => {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
// Create a mock event for zapping // Create a mock event for zapping
const profileEvent = author.data?.event; const profileEvent = author.data?.event;
@ -298,10 +324,10 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{/* ── At-a-glance stats strip ── */} {/* ── At-a-glance stats strip ── */}
<div className="flex flex-wrap items-stretch gap-2 sm:gap-3 mb-6"> <div className="flex flex-wrap items-stretch gap-2 sm:gap-3 mb-6">
<StatPill value={spots?.length ?? 0} label="Spots" color="#ff0040" loading={spotsLoading} /> <StatPill value={spots?.length ?? 0} label="Spots" color="#ff0040" loading={spotsLoading} onClick={() => scrollToSection('section-spots')} />
<StatPill value={shops?.length ?? 0} label="Shops" color="#ccff00" loading={shopsLoading} /> <StatPill value={shops?.length ?? 0} label="Shops" color="#ccff00" loading={shopsLoading} onClick={() => scrollToSection('section-shops')} />
<StatPill value={crews.length} label="Crews" color="#2dd4bf" loading={crewsLoading} /> <StatPill value={crews.length} label="Crews" color="#2dd4bf" loading={crewsLoading} onClick={() => scrollToSection('section-crews')} />
<StatPill value={publicStash?.length ?? 0} label="Gear" color="#ff6600" loading={publicStashLoading} /> <StatPill value={publicStash?.length ?? 0} label="Gear" color="#ff6600" loading={publicStashLoading} onClick={() => scrollToSection('section-stash')} />
{(riderStats?.zapsReceived ?? 0) > 0 && ( {(riderStats?.zapsReceived ?? 0) > 0 && (
<StatPill value={riderStats!.zapsReceived} label="Zaps" color="#facc15" icon={<Zap className="h-3.5 w-3.5" />} /> <StatPill value={riderStats!.zapsReceived} label="Zaps" color="#facc15" icon={<Zap className="h-3.5 w-3.5" />} />
)} )}
@ -459,7 +485,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{/* Spots by this user — hidden entirely for others when empty */} {/* Spots by this user — hidden entirely for others when empty */}
{(spotsLoading || (spots && spots.length > 0) || isOwnProfile) && ( {(spotsLoading || (spots && spots.length > 0) || isOwnProfile) && (
<div> <div id="section-spots" className="scroll-mt-24">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2"> <h2 className="text-xl font-black uppercase flex items-center gap-2">
<MapPin className="h-5 w-5 text-[#ff0040]" /> <MapPin className="h-5 w-5 text-[#ff0040]" />
@ -502,7 +528,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{/* Shops by this user — only render when there's something, or it's you */} {/* Shops by this user — only render when there's something, or it's you */}
{(shopsLoading || (shops && shops.length > 0)) && ( {(shopsLoading || (shops && shops.length > 0)) && (
<div> <div id="section-shops" className="scroll-mt-24">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2"> <h2 className="text-xl font-black uppercase flex items-center gap-2">
<Store className="h-5 w-5 text-[#ccff00]" /> <Store className="h-5 w-5 text-[#ccff00]" />
@ -547,7 +573,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{/* Public Stash — gear this skater chose to show publicly */} {/* Public Stash — gear this skater chose to show publicly */}
{(publicStashLoading || (publicStash && publicStash.length > 0)) && ( {(publicStashLoading || (publicStash && publicStash.length > 0)) && (
<div> <div id="section-stash" className="scroll-mt-24">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2"> <h2 className="text-xl font-black uppercase flex items-center gap-2">
<Package className="h-5 w-5 text-[#ff6600]" /> <Package className="h-5 w-5 text-[#ff6600]" />
@ -763,7 +789,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{/* Crews Card */} {/* Crews Card */}
{(crewsLoading || crews.length > 0) && ( {(crewsLoading || crews.length > 0) && (
<Card className="bg-black/90 border-2 border-teal-500/30"> <Card id="section-crews" className="scroll-mt-24 bg-black/90 border-2 border-teal-500/30">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5 text-teal-400" /> <Users className="h-5 w-5 text-teal-400" />