~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).
153 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|