Fix mobile grid overflow + add a Top Shops leaderboard
The /lab profile card and /stats Top Mappers were overflowing off the right edge: Tailwind `grid md:grid-cols-2` (no base column) renders one implicit `auto` track on mobile that can exceed the container when a name is long, so the whole card got pushed off-screen. Added the base `grid-cols-1` repo-wide (plus `min-w-0` on truncating flex/grid children). /stats also gained a "Top Shops — most shops added" leaderboard (kind 30017) beside Top Mappers (spots): `topShops` in useGrowthStats, a generalized MapperRow (count + unit), and EN/ES copy. PERSIST_BUSTER → v6 (new persisted field).
This commit is contained in:
parent
fb42f4f0d1
commit
084c002c45
13 changed files with 106 additions and 24 deletions
|
|
@ -1257,6 +1257,14 @@ session with **no other memory of this project**. So:
|
|||
with the QR; the `bunker://` paste stays as the fallback. ⚠️ **NOT verified
|
||||
against a real signer — needs an on-device check with Amber** (scan → pair →
|
||||
signed in); only the URI shape was verified in Node.
|
||||
- **Mobile grid overflow gotcha (2026-10-04):** a Tailwind grid **must** declare a
|
||||
base column — `grid grid-cols-1 md:grid-cols-2`, **not** `grid md:grid-cols-2`.
|
||||
Without the base, mobile renders one implicit `auto` track that can exceed the
|
||||
container, so a long name (e.g. a `/lab` `ProfileCard`, or `/stats` Top Mappers)
|
||||
pushes the whole card off the right edge and clips the values. Fixed repo-wide;
|
||||
also add `min-w-0` to truncating flex/grid children. `/stats` also gained a
|
||||
**Top Shops** leaderboard (`topShops` in `useGrowthStats`; `PERSIST_BUSTER` → v6)
|
||||
alongside Top Mappers (spots).
|
||||
- **Community stats (`/stats`, 2026-10-01):** `useGrowthStats` + `CommunityStatsPage`.
|
||||
- ⚠️ **A relay outage used to render a board of ZEROS as truth** — every
|
||||
sub-query ended in `.catch(() => [])`. The queryFn now counts failures (a
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const SignupMethodDialog: React.FC<SignupMethodDialogProps> = ({
|
|||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6 mt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
{/* Mnemonic Option (Recommended) */}
|
||||
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-[#00ff00]/5 border-2 border-[#ccff00]/50 hover:border-[#ccff00] transition-all cursor-pointer group"
|
||||
onClick={onSelectMnemonic}>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ export interface TopMapper {
|
|||
totalContent: number;
|
||||
}
|
||||
|
||||
export interface TopShop {
|
||||
pubkey: string;
|
||||
shopCount: number;
|
||||
}
|
||||
|
||||
export interface GrowthStats {
|
||||
// ── Totals ──────────────────────────────────────────────────────────────
|
||||
totalSpots: number;
|
||||
|
|
@ -92,6 +97,8 @@ export interface GrowthStats {
|
|||
|
||||
// ── Leaderboard ─────────────────────────────────────────────────────────
|
||||
topMappers: TopMapper[];
|
||||
/** Top shops by shop count (kind 30017). */
|
||||
topShops: TopShop[];
|
||||
|
||||
// ── Marketplace ──────────────────────────────────────────────────────────
|
||||
/** Live NIP-99 listings (stash "showcase-only" items excluded). */
|
||||
|
|
@ -485,6 +492,14 @@ export function useGrowthStats() {
|
|||
totalContent: mapperTotal.get(pubkey) ?? spotCount,
|
||||
}));
|
||||
|
||||
// ── Top shops by shop count ──────────────────────────────────────────
|
||||
const mapperShops = new Map<string, number>();
|
||||
for (const e of shops) { mapperShops.set(e.pubkey, (mapperShops.get(e.pubkey) ?? 0) + 1); }
|
||||
const topShops: TopShop[] = Array.from(mapperShops.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([pubkey, shopCount]) => ({ pubkey, shopCount }));
|
||||
|
||||
// ── Unique mappers ───────────────────────────────────────────────────
|
||||
|
||||
const allPubkeys = new Set(allContent.map(e => e.pubkey));
|
||||
|
|
@ -552,6 +567,7 @@ export function useGrowthStats() {
|
|||
monthly,
|
||||
cumulativeSpots,
|
||||
topMappers,
|
||||
topShops,
|
||||
totalListings: listings.length,
|
||||
totalTrades: trades.length,
|
||||
tradedSats,
|
||||
|
|
|
|||
|
|
@ -6337,6 +6337,10 @@ walletUi: {
|
|||
topMappers: 'Top Mappers',
|
||||
mostSpots: '— most spots added',
|
||||
noMapperData: 'No mapper data yet',
|
||||
topShops: 'Top Shops',
|
||||
mostShops: '— most shops added',
|
||||
shopCount: '{{count}} shops',
|
||||
noShopData: 'No shop data yet',
|
||||
supporters: 'Community Supporters',
|
||||
fundedTitle: 'Funded by the Community',
|
||||
fundedDesc1: 'Decentralized Bitcoin crowdfunding via Angor.',
|
||||
|
|
|
|||
|
|
@ -6337,6 +6337,10 @@ walletUi: {
|
|||
topMappers: 'Mejores mapeadores',
|
||||
mostSpots: '— más spots añadidos',
|
||||
noMapperData: 'Aún no hay datos de mapeadores',
|
||||
topShops: 'Mejores tiendas',
|
||||
mostShops: '— más tiendas añadidas',
|
||||
shopCount: '{{count}} tiendas',
|
||||
noShopData: 'Aún no hay datos de tiendas',
|
||||
supporters: 'Patrocinadores de la comunidad',
|
||||
fundedTitle: 'Financiado por la comunidad',
|
||||
fundedDesc1: 'Crowdfunding descentralizado en Bitcoin vía Angor.',
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import type { Query } from '@tanstack/react-query';
|
|||
const IDB_KEY = 'skatespots:react-query-cache';
|
||||
|
||||
/** Bump when the persisted data shape changes to invalidate old caches. */
|
||||
export const PERSIST_BUSTER = 'v5';
|
||||
export const PERSIST_BUSTER = 'v6';
|
||||
|
||||
/** Discard any persisted cache older than this (ms). */
|
||||
export const PERSIST_MAX_AGE = 24 * 60 * 60 * 1000; // 24h
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ Version: v0.1.0`;
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg bg-black/50 border border-[#ff0040]/20">
|
||||
<Shield className="h-6 w-6 text-[#ff0040] mb-2" />
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-2">{t('settingsUi.about.why1Title')}</h3>
|
||||
|
|
@ -147,7 +147,7 @@ Version: v0.1.0`;
|
|||
</Card>
|
||||
|
||||
{/* Links */}
|
||||
<div className="grid md:grid-cols-1 gap-6 max-w-md mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-6 max-w-md mx-auto">
|
||||
<Card className="bg-black/90 border-2 border-[#ccff00]/50">
|
||||
<CardContent className="p-6 text-center">
|
||||
<h3 className="font-black uppercase text-[#ccff00] mb-4">{t('settingsUi.about.getInvolved')}</h3>
|
||||
|
|
@ -254,7 +254,7 @@ Version: v0.1.0`;
|
|||
<Heart className="h-5 w-5 text-[#ff0040]" />
|
||||
{t('settingsUi.about.credits')}
|
||||
</h3>
|
||||
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
{[
|
||||
{ label: t('settingsUi.about.creditPoweredBy'), name: 'Nostr', url: 'https://github.com/nostr-protocol/nostr', desc: t('settingsUi.about.creditNostr') },
|
||||
{ label: t('settingsUi.about.creditCrewChat'), name: 'Cordn', url: 'https://cordn.net/', desc: t('settingsUi.about.creditCordn') },
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ function StatCard({
|
|||
|
||||
// ─── Top mapper row ──────────────────────────────────────────────────────────
|
||||
|
||||
function MapperRow({ pubkey, rank, spotCount }: { pubkey: string; rank: number; spotCount: number }) {
|
||||
function MapperRow({ pubkey, rank, count, unit }: { pubkey: string; rank: number; count: number; unit: 'spots' | 'shops' }) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(pubkey);
|
||||
const meta = author.data?.metadata;
|
||||
|
|
@ -144,7 +144,7 @@ function MapperRow({ pubkey, rank, spotCount }: { pubkey: string; rank: number;
|
|||
const rankColors: Record<number, string> = { 1: '#f7931a', 2: '#c0c0c0', 3: '#cd7f32' };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2.5 border-b border-white/5 last:border-0">
|
||||
<div className="flex min-w-0 items-center gap-3 py-2.5 border-b border-white/5 last:border-0">
|
||||
<span className="text-xs font-black w-5 flex-shrink-0 text-center" style={{ color: rankColors[rank] ?? '#555' }}>
|
||||
{rank <= 3 ? ['🥇','🥈','🥉'][rank - 1] : rank}
|
||||
</span>
|
||||
|
|
@ -154,9 +154,9 @@ function MapperRow({ pubkey, rank, spotCount }: { pubkey: string; rank: number;
|
|||
{name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 text-sm font-semibold truncate">{name}</span>
|
||||
<span className="min-w-0 flex-1 text-sm font-semibold truncate">{name}</span>
|
||||
<span className="text-xs font-bold flex-shrink-0" style={{ color: '#ccff00' }}>
|
||||
{t('walletUi.stats.spotCount', { count: spotCount })}
|
||||
{t(unit === 'shops' ? 'walletUi.stats.shopCount' : 'walletUi.stats.spotCount', { count })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -389,7 +389,7 @@ export default function CommunityStatsPage() {
|
|||
{t('walletUi.stats.resolving', { count: derivingPending })}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{topCountries.length > 0 && (
|
||||
<Card className="border-white/8">
|
||||
<CardHeader className="pb-2">
|
||||
|
|
@ -479,7 +479,7 @@ export default function CommunityStatsPage() {
|
|||
<TrendingUp className="h-3.5 w-3.5" /> {t('walletUi.stats.growth')}
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
{/* Cumulative spots — area chart */}
|
||||
<Card className="bg-black/80 border border-[#ff0040]/20">
|
||||
|
|
@ -605,7 +605,7 @@ export default function CommunityStatsPage() {
|
|||
<span className="text-gray-600 font-normal normal-case tracking-normal">{t('walletUi.stats.mostSpots')}</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Card className="bg-black/80 border border-white/8">
|
||||
|
|
@ -638,14 +638,14 @@ export default function CommunityStatsPage() {
|
|||
<Card className="bg-black/80 border border-white/8">
|
||||
<CardContent className="pt-5">
|
||||
{(stats.topMappers ?? []).slice(0, 5).map((m, i) => (
|
||||
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 1} spotCount={m.spotCount} />
|
||||
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 1} count={m.spotCount} unit="spots" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-black/80 border border-white/8">
|
||||
<CardContent className="pt-5">
|
||||
{stats.topMappers.slice(5, 10).map((m, i) => (
|
||||
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 6} spotCount={m.spotCount} />
|
||||
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 6} count={m.spotCount} unit="spots" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -658,6 +658,56 @@ export default function CommunityStatsPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Section 3b: Top shops ── */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
||||
<Store className="h-3.5 w-3.5" /> {t('walletUi.stats.topShops')}
|
||||
<span className="text-gray-600 font-normal normal-case tracking-normal">{t('walletUi.stats.mostShops')}</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{isLoading ? (
|
||||
<>
|
||||
{[0, 1].map((c) => (
|
||||
<Card key={c} className="bg-black/80 border border-white/8">
|
||||
<CardContent className="pt-5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 py-2.5 border-b border-white/5 last:border-0">
|
||||
<Skeleton className="w-5 h-4" />
|
||||
<Skeleton className="w-8 h-8 rounded-full" />
|
||||
<Skeleton className="flex-1 h-4" />
|
||||
<Skeleton className="w-12 h-4" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
) : stats && (stats.topShops ?? []).length > 0 ? (
|
||||
<>
|
||||
<Card className="bg-black/80 border border-white/8">
|
||||
<CardContent className="pt-5">
|
||||
{(stats.topShops ?? []).slice(0, 5).map((m, i) => (
|
||||
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 1} count={m.shopCount} unit="shops" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-black/80 border border-white/8">
|
||||
<CardContent className="pt-5">
|
||||
{(stats.topShops ?? []).slice(5, 10).map((m, i) => (
|
||||
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 6} count={m.shopCount} unit="shops" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<div className="col-span-2 text-center py-10 text-gray-600 text-sm">
|
||||
{t('walletUi.stats.noShopData')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Section 4: Supporters ── */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -1116,8 +1116,8 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
|
|||
{/* Name row */}
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-black text-sm text-white truncate leading-tight flex items-center gap-1.5">
|
||||
<span className="truncate">{displayName}</span>
|
||||
<h3 className="font-black text-sm text-white truncate leading-tight flex items-center gap-1.5 min-w-0">
|
||||
<span className="truncate min-w-0">{displayName}</span>
|
||||
<ProBadge featuredUntil={profile.featuredUntil} compact className="flex-shrink-0" />
|
||||
{profile.entityType === 'collective' && (
|
||||
<span
|
||||
|
|
@ -1657,11 +1657,11 @@ export default function LabPage() {
|
|||
{/* ── Results grid ── */}
|
||||
<div className="mt-5">
|
||||
{isLoading ? (
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
) : filteredProfiles.length > 0 ? (
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{filteredProfiles.map((profile) => (
|
||||
<LazyMount key={profile.id} className="h-full">
|
||||
<ProfileCard profile={profile} />
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export default function MarketplacePage() {
|
|||
{/* What You Can Buy/Sell */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-black uppercase text-white mb-6">{t('marketUi.marketplace.buySellTitle')}</h2>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[
|
||||
{
|
||||
title: t('marketUi.marketplace.catCompletes'),
|
||||
|
|
|
|||
|
|
@ -314,7 +314,7 @@ export default function OnboardingPage() {
|
|||
{/* ── Why skaters care (collapsed by default — keeps the first view light) ── */}
|
||||
<section>
|
||||
<CollapsibleSection title={t('settingsUi.onboarding.whyBother')}>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{[
|
||||
{
|
||||
emoji: '🗺️',
|
||||
|
|
@ -429,7 +429,7 @@ export default function OnboardingPage() {
|
|||
<p className="text-sm text-gray-400 leading-relaxed mb-4">
|
||||
{t('settingsUi.onboarding.walletDesc')}
|
||||
</p>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="p-4 rounded-xl bg-orange-500/8 border border-orange-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Nut className="h-4 w-4 text-orange-400" />
|
||||
|
|
@ -574,7 +574,7 @@ export default function OnboardingPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* Beta coordinator */}
|
||||
<a
|
||||
href="https://cordn.net/p/npub1e2ayjlmmaksx2rtt0z4jzyskunfwh2lwe89xav4qtnhgu4wxsudsp98a4l"
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export default function PrivacyPage() {
|
|||
<p>
|
||||
{t('settingsUi.privacy.vpnDesc')}
|
||||
</p>
|
||||
<div className="grid sm:grid-cols-2 gap-3 my-1">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 my-1">
|
||||
<div className="p-3.5 rounded-xl bg-white/4 border border-white/8">
|
||||
<p className="font-bold text-white text-sm mb-1">{t('settingsUi.privacy.vpnReputable')}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
|
|
|
|||
|
|
@ -654,7 +654,7 @@ export default function RiderCardPage() {
|
|||
</div>
|
||||
|
||||
{pageTab === 'card' && (
|
||||
<div className="lg:grid lg:grid-cols-[minmax(0,1fr)_400px] lg:items-start lg:gap-8">
|
||||
<div className="lg:grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_400px] lg:items-start lg:gap-8">
|
||||
|
||||
{/* ══ LEFT: sticky preview + actions ══ */}
|
||||
<div className="space-y-4 lg:sticky lg:top-20">
|
||||
|
|
|
|||
Loading…
Reference in a new issue