SkateSpots/src/components/AttendeeList.tsx
SkateSpots 0b796440f1 feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats
~3,470 new keys (5,770 total, EN=ES), ~115 more files wired.
- nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets
  (sort/trust/weather), spot-type label helper.
- placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications,
  collectives, crew chat/coordinator. settingsUi: settings, auth/signup,
  onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints,
  app shop, newsletter, community stats, leaderboards, profile.
- Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
2026-10-02 10:11:06 +07:00

153 lines
5.1 KiB
TypeScript

import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { ShieldCheck, Users } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
const TRUSTED_THRESHOLD = 60;
function AttendeeRow({ pubkey, trusted }: { pubkey: string; trusted: boolean }) {
const { t } = useTranslation();
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name ?? genUserName(pubkey);
const pic = author.data?.metadata?.picture;
const npub = (() => {
try {
return nip19.npubEncode(pubkey);
} catch {
return null;
}
})();
const inner = (
<div className="flex items-center gap-3 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/5">
<Avatar className="h-8 w-8">
<AvatarImage src={pic} alt={name} />
<AvatarFallback className="text-[10px] font-bold">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span>
{trusted && (
<span
className="flex items-center gap-1 rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-[#ccff00]"
title={t('placesUi.attendees.trustedTitle')}
>
<ShieldCheck className="h-3 w-3" />
{t('placesUi.attendees.trusted')}
</span>
)}
</div>
);
if (!npub) return inner;
return <Link to={`/${npub}`}>{inner}</Link>;
}
/**
* Full attendee list for a host: tabbed Going / Interested, each row showing a
* face + name, plus a "trust signal" summarizing how many of the Going crowd
* are people the viewer follows or trusted mappers (skate events live or die on
* whether real locals show up).
*/
export function AttendeeList({
going,
tentative,
className,
}: {
going: string[];
tentative: string[];
className?: string;
}) {
const { t } = useTranslation();
const [tab, setTab] = useState<'going' | 'tentative'>('going');
const allPubkeys = useMemo(() => [...new Set([...going, ...tentative])], [going, tentative]);
const { data: trustScores } = useBatchTrustScores(allPubkeys);
const trustedGoing = useMemo(
() => going.filter((pk) => (trustScores?.get(pk) ?? 0) >= TRUSTED_THRESHOLD).length,
[going, trustScores],
);
if (going.length === 0 && tentative.length === 0) {
return (
<p className={cn('text-sm text-muted-foreground', className)}>
{t('placesUi.attendees.noRsvps')}
</p>
);
}
const list = tab === 'going' ? going : tentative;
return (
<div className={cn('space-y-3', className)}>
{/* Trust signal */}
{going.length > 0 && (
<div className="flex items-center gap-2 rounded-lg border border-[#ccff00]/20 bg-[#ccff00]/5 px-3 py-2">
<Users className="h-4 w-4 flex-shrink-0 text-[#ccff00]" />
<p className="text-xs text-gray-300">
<span className="font-black text-white">{going.length}</span> {t('placesUi.attendees.goingCount')}
{trustedGoing > 0 && (
<>
{' · '}
<span className="font-black text-[#ccff00]">{trustedGoing}</span> {t('placesUi.attendees.youTrust')}
</>
)}
</p>
</div>
)}
{/* Tabs */}
<div className="flex gap-1.5">
<button
type="button"
onClick={() => setTab('going')}
className={cn(
'flex-1 rounded-lg py-1.5 text-xs font-black uppercase tracking-wide transition-all',
tab === 'going'
? 'bg-green-500 text-black'
: 'bg-white/5 text-gray-400 hover:text-white',
)}
>
{t('placesUi.attendees.goingTab', { count: going.length })}
</button>
<button
type="button"
onClick={() => setTab('tentative')}
className={cn(
'flex-1 rounded-lg py-1.5 text-xs font-black uppercase tracking-wide transition-all',
tab === 'tentative'
? 'bg-[#ccff00] text-black'
: 'bg-white/5 text-gray-400 hover:text-white',
)}
>
{t('placesUi.attendees.interestedTab', { count: tentative.length })}
</button>
</div>
{/* List */}
{list.length > 0 ? (
<div className="max-h-72 space-y-0.5 overflow-y-auto pr-1">
{list.map((pk) => (
<AttendeeRow
key={pk}
pubkey={pk}
trusted={(trustScores?.get(pk) ?? 0) >= TRUSTED_THRESHOLD}
/>
))}
</div>
) : (
<p className="py-4 text-center text-sm text-muted-foreground">
{tab === 'going' ? t('placesUi.attendees.nobodyGoing') : t('placesUi.attendees.nobodyInterested')}
</p>
)}
</div>
);
}