- 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).
256 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|