SkateSpots/src/components/CollectiveMembers.tsx
SkateSpots e357833557 i18n(es): batch 3 part 3 — shared label maps + leftover routing/Sasquatch strings
- labelsUi.* (221+ keys): spot categories/features/types, access, shelter, event
  types, gear categories/conditions, price/listings, tours, crews,
  member-visibility, tag categories, spot status. New src/lib/labelKeys.ts holds
  the shared value->key maps (one source; the five duplicated EVENT_TYPE_KEYS
  copies now import it). spotStatus.statusTimeAgo() uses the locale date shim.
- formatPrice() now takes an optional t so FREE/Trade/Make Offer translate.
- Route helpers: skateTime/distanceReadout/maneuver descriptors/formatAge
  translated (routeUi.*), consumers updated.
- Sasquatch: PRO_TIERS labels/badges + summarizeDraft titles/lines/ctas
  translated (sasquatchUi.*).
- Lab *_LABELS/_DESCRIPTIONS maps remain English (documented out of scope).
2026-10-02 15:31:27 +07:00

256 lines
9.1 KiB
TypeScript

import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Users, Check, Clock, ShieldCheck, EyeOff } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { useCollectiveMembers, type CollectiveMember } from '@/hooks/useCollectiveMembers';
import { CREW_ROLE_META, type CrewRole } from '@/lib/types';
import { CREW_ROLE_KEYS } from '@/lib/labelKeys';
import { cn } from '@/lib/utils';
interface CollectiveMembersProps {
kind: number;
founderPubkey: string;
identifier: string;
claimedMembers: string[];
/** Delegated admin pubkeys (subset of members). */
admins?: string[];
/** Optional skate roles per member (pubkey → roles). Renders flex chips. */
rolesByPubkey?: Map<string, CrewRole[]>;
accentText?: string;
accentBorder?: string;
}
/** Skate-role flex chip (🎥 Filmer, 📷 Photographer, …). */
function RoleChip({ role }: { role: CrewRole }) {
const { t } = useTranslation();
const meta = CREW_ROLE_META[role];
return (
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs font-bold text-gray-200">
<span className="leading-none">{meta.emoji}</span> {CREW_ROLE_KEYS[role] ? t(CREW_ROLE_KEYS[role]) : meta.label}
</span>
);
}
function StatusChip({ status }: { status: CollectiveMember['status'] }) {
const { t } = useTranslation();
if (status === 'confirmed') {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 text-xs font-bold text-green-500">
<Check className="h-3 w-3" /> {t('socialUi.collectiveMembers.confirmed')}
</span>
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs font-bold text-amber-500">
<Clock className="h-3 w-3" /> {t('socialUi.collectiveMembers.pending')}
</span>
</TooltipTrigger>
<TooltipContent>
{t('socialUi.collectiveMembers.pendingTooltip')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function AdminChip() {
const { t } = useTranslation();
return (
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/15 px-2 py-0.5 text-xs font-bold text-teal-300">
<ShieldCheck className="h-3 w-3" /> {t('socialUi.inviteCard.admin')}
</span>
);
}
function CrewOnlyChip() {
const { t } = useTranslation();
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs font-bold text-gray-300">
<EyeOff className="h-3 w-3" /> {t('socialUi.collectiveMembers.crewOnly')}
</span>
</TooltipTrigger>
<TooltipContent>
{t('socialUi.collectiveMembers.crewOnlyTooltip')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function MemberRow({
member,
isAdmin,
roles,
}: {
member: CollectiveMember;
isAdmin: boolean;
roles?: CrewRole[];
}) {
const author = useAuthor(member.pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(member.pubkey);
const npub = nip19.npubEncode(member.pubkey);
return (
<Link
to={`/${npub}`}
className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg bg-black/40 p-2 transition-colors hover:bg-black/60"
>
<Avatar className="h-9 w-9">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span>
{roles?.map((r) => <RoleChip key={r} role={r} />)}
{member.visibility === 'crew' && <CrewOnlyChip />}
{isAdmin && <AdminChip />}
<StatusChip status={member.status} />
</Link>
);
}
/** Anonymous placeholder shown to NON-members for a `crew`-only member. */
function HiddenMemberRow() {
const { t } = useTranslation();
return (
<div className="flex items-center gap-3 rounded-lg bg-black/40 p-2">
<Avatar className="h-9 w-9">
<AvatarFallback className="bg-white/5 text-gray-600">
<EyeOff className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-500 italic">
{t('socialUi.collectiveMembers.hiddenMember')}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-bold text-gray-500">
<EyeOff className="h-3 w-3" /> {t('socialUi.collectiveMembers.crewOnly')}
</span>
</div>
);
}
/**
* Renders a collective's members with confirmed / pending trust chips.
* Returns null for individuals or collectives with no listed members.
*/
export function CollectiveMembers({
kind,
founderPubkey,
identifier,
claimedMembers,
admins = [],
rolesByPubkey,
accentText = 'text-indigo-400',
accentBorder = 'border-gray-700',
}: CollectiveMembersProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: members, isLoading } = useCollectiveMembers({
kind,
founderPubkey,
identifier,
claimedMembers,
enabled: claimedMembers.length > 0,
});
if (claimedMembers.length === 0) return null;
const adminSet = new Set(admins);
const confirmedCount = members?.filter((m) => m.status === 'confirmed').length ?? 0;
// The viewer counts as a crew-mate (can see crew-only members) if they are
// the founder or a claimed member of this crew.
const viewerIsCrewmate =
!!user && (user.pubkey === founderPubkey || claimedMembers.includes(user.pubkey));
// Admins first, then the hook's existing confirmed-first ordering.
const orderedMembers = members
? [...members].sort((a, b) => {
const aa = adminSet.has(a.pubkey) ? 1 : 0;
const ba = adminSet.has(b.pubkey) ? 1 : 0;
return ba - aa;
})
: members;
// Split into what the viewer may see vs. anonymised "hidden member" slots.
// A crew-only member is always visible to themselves.
const isHiddenFromViewer = (m: CollectiveMember) =>
m.visibility === 'crew' && !viewerIsCrewmate && m.pubkey !== user?.pubkey;
const visibleMembers = orderedMembers?.filter((m) => !isHiddenFromViewer(m)) ?? [];
const hiddenCount = orderedMembers?.filter(isHiddenFromViewer).length ?? 0;
return (
<Card className={cn('bg-black/90 border-2', accentBorder)}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Users className={cn('h-5 w-5', accentText)} />
{t('socialUi.collectiveMembers.title')} · {claimedMembers.length}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="flex items-start gap-1.5 text-xs text-gray-400">
<ShieldCheck className={cn('mt-0.5 h-3.5 w-3.5 shrink-0', accentText)} />
<span>
{confirmedCount > 0
? t('socialUi.collectiveMembers.verifiedOf', { confirmed: confirmedCount, total: claimedMembers.length })
: t('socialUi.collectiveMembers.verifiedPerMember')}
{admins.length > 0 &&
(admins.length === 1
? ` ${t('socialUi.collectiveMembers.adminsOne', { count: admins.length })}`
: ` ${t('socialUi.collectiveMembers.adminsMany', { count: admins.length })}`)}
</span>
</p>
{isLoading ? (
<div className="space-y-2">
{claimedMembers.slice(0, 3).map((_, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg bg-black/40 p-2">
<Skeleton className="h-9 w-9 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-5 w-20 rounded-full" />
</div>
))}
</div>
) : (
<div className="space-y-2">
{visibleMembers.map((m) => (
<MemberRow
key={m.pubkey}
member={m}
isAdmin={adminSet.has(m.pubkey)}
roles={rolesByPubkey?.get(m.pubkey)}
/>
))}
{/* Collapse all crew-only members into anonymous slots for outsiders */}
{hiddenCount > 0 && (
<>
{Array.from({ length: hiddenCount }).map((_, i) => (
<HiddenMemberRow key={`hidden-${i}`} />
))}
</>
)}
</div>
)}
</CardContent>
</Card>
);
}