SkateSpots/src/components/NotificationBell.tsx

606 lines
28 KiB
TypeScript

import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Bell, Users, Check, Loader2, EyeOff, Globe, CheckCheck, Heart, Radio, ArrowRight, Handshake, MessageCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { CrewInviteCard } from '@/components/CrewInviteCard';
import { CordnJoinRequestCard } from '@/components/CordnJoinRequestCard';
import { JoinRequestCard } from '@/components/JoinRequestCard';
import { CollectiveJoinRequestCard } from '@/components/CollectiveJoinRequestCard';
import { ActivityNotificationCard } from '@/components/ActivityNotificationCard';
import { TradeNotificationCard } from '@/components/TradeNotificationCard';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
import { useCordnJoinNotifications } from '@/hooks/useCordnJoinNotifications';
import { useDmUnreadSummary } from '@/hooks/useDmUnreadSummary';
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
import { useTradeNotifications } from '@/hooks/useTradeNotifications';
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
import { useNewsletterRead } from '@/hooks/useNewsletterRead';
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { NEWSLETTER_KIND, SKATE_CREW_KIND, type MemberVisibility, type SkateCrew, type SkateNewsletterIssue } from '@/lib/types';
import { cn } from '@/lib/utils';
/**
* A pending-confirm crew: this user has been LISTED in a crew's member list but
* hasn't published their own attestation yet. Rendered like an invite so they
* can confirm (with a visibility choice) right from the bell.
*/
function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?: () => void }) {
const { t } = useTranslation();
const isPending = useIsPendingMember(crew);
const founder = useAuthor(crew.pubkey);
const founderName = founder.data?.metadata?.name ?? genUserName(crew.pubkey);
const founderPic = founder.data?.metadata?.picture;
const { toast } = useToast();
const { mutateAsync: confirm, isPending: confirming } = useConfirmMembership();
const [visibility, setVisibility] = useState<MemberVisibility>('public');
const [done, setDone] = useState(false);
// Only actionable while the member is genuinely pending confirmation.
if (!isPending || done) return null;
const handleConfirm = async () => {
try {
await confirm({
kind: SKATE_CREW_KIND,
founderPubkey: crew.pubkey,
identifier: crew.identifier,
collectiveName: crew.name,
visibility,
});
setDone(true);
toast({ title: t('socialUi.bell.youAreIn', { crew: crew.name }), description: t('socialUi.bell.membershipConfirmed') });
onResolved?.();
} catch (err) {
toast({
title: t('socialUi.bell.couldNotConfirm'),
description: err instanceof Error ? err.message : t('common.tryAgain'),
variant: 'destructive',
});
}
};
return (
<div className="rounded-xl border border-teal-500/25 bg-gradient-to-br from-teal-500/[0.08] to-transparent p-3">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-teal-500/15">
<Users className="h-4 w-4 text-teal-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-gray-200">
<span className="inline-flex items-center gap-1 font-bold text-white">
<Avatar className="h-4 w-4">
<AvatarImage src={founderPic} alt={founderName} />
<AvatarFallback className="text-[8px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
{founderName}
</span>{' '}
{t('socialUi.bell.addedYouTo')}{' '}
<Link to={`/crew/${crew.pubkey}/${crew.identifier}`} className="font-black text-teal-300 hover:underline">
{crew.name}
</Link>
. {t('socialUi.bell.confirmToAppear')}
</p>
{/* Visibility toggle */}
<div className="mt-2 grid grid-cols-2 gap-1.5">
{([
{ key: 'public', label: t('socialUi.confirmMembership.public'), icon: Globe },
{ key: 'crew', label: t('socialUi.confirmMembership.crewOnly'), icon: EyeOff },
] as const).map(({ key, label, icon: Icon }) => {
const active = visibility === key;
return (
<button
key={key}
type="button"
onClick={() => setVisibility(key)}
className={cn(
'flex items-center justify-center gap-1 rounded-lg border px-2 py-1.5 text-xs font-bold transition-all active:scale-95',
active
? 'border-transparent bg-teal-500 text-black'
: 'border-white/10 bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white',
)}
>
<Icon className="h-3 w-3" />
{label}
</button>
);
})}
</div>
<Button
size="sm"
onClick={handleConfirm}
disabled={confirming}
className="mt-2.5 h-8 w-full gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
>
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
{t('socialUi.confirmMembership.confirmButton')}
</Button>
</div>
</div>
</div>
);
}
/** A newsletter issue row in the bell's News tab. */
function NewsletterItem({
issue,
unread,
onNavigate,
}: {
issue: SkateNewsletterIssue;
unread: boolean;
onNavigate: () => void;
}) {
const { t } = useTranslation();
const naddr = nip19.naddrEncode({
kind: NEWSLETTER_KIND,
pubkey: issue.pubkey,
identifier: issue.identifier,
});
const date = new Date(issue.publishedAt * 1000).toLocaleDateString(dateLocaleCode(), {
month: 'short',
day: 'numeric',
});
return (
<Link
to={`/${naddr}`}
onClick={onNavigate}
className={cn(
'group flex gap-3 rounded-xl border p-3 transition-all hover:border-[#ccff00]/50 hover:bg-white/[0.06]',
unread ? 'border-[#ccff00]/30 bg-[#ccff00]/[0.06]' : 'border-white/10 bg-white/[0.02]',
)}
>
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl bg-[#ccff00]/15">
<ImageWithFallback
src={issue.image}
alt=""
className="h-full w-full object-cover"
fallback={<Radio className="h-4 w-4 text-[#ccff00]" />}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-black text-white">{issue.title}</p>
{unread && <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[#ccff00]" />}
</div>
{issue.summary && <p className="line-clamp-2 text-xs text-gray-400">{issue.summary}</p>}
<p className="mt-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-gray-600">
{date} · {t('socialUi.bell.read')} <ArrowRight className="h-2.5 w-2.5" />
</p>
</div>
</Link>
);
}
function EmptyState({ label }: { label: string }) {
const { t } = useTranslation();
return (
<SharedEmptyState
icon={Bell}
title={t('socialUi.bell.allCaughtUp')}
hint={label}
className="py-10"
/>
);
}
/**
* Header notification bell — the user's whole activity center, not just crews:
*
* SOCIAL: reactions, reposts, zaps, mentions and replies that reference them.
* CREWS: structured invites, "pending confirmation" crews, and (for crew
* leaders) incoming join-requests to approve.
*
* Unread state is tracked by a per-account "last read" timestamp; opening the
* bell (or hitting "mark all read") clears the badge. Only renders when logged in.
*/
export function NotificationBell({
className,
defaultOpen = false,
}: {
className?: string;
defaultOpen?: boolean;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(defaultOpen);
const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
useCrewNotifications();
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
const {
requests: cordnJoinRequests,
isLoading: cordnJoinLoading,
dismiss: dismissCordnJoin,
} = useCordnJoinNotifications(open);
const { unreadCount: unreadDms, markSeen: markDmEnvelopesSeen } = useDmUnreadSummary(!!user);
const { lastRead, seenAt, markAllRead } = useNotificationsRead();
// Buying/selling steps: an offer on your listing, a counter it's your turn to
// answer, or a request to confirm a completed trade.
const { notifications: tradeNotifs, isLoading: tradeLoading } = useTradeNotifications(user?.pubkey);
// Newsletter mailbox — only nags subscribers, but always browsable in the tab.
const { data: newsletterIssues = [], isLoading: newsletterLoading } = useNewsletterIssues();
const { lastRead: newsletterLastRead, markNewsletterRead } = useNewsletterRead();
const { isSubscribed } = useNewsletterSubscription();
// Total actionable crew items — these always RENDER in the Crews/All tabs
// until the user acts on them (accept/decline/confirm).
const crewTotal = useMemo(
() =>
invites.length +
listedCrews.length +
joinRequests.length +
collectiveRequests.length +
cordnJoinRequests.length,
[invites, listedCrews, joinRequests, collectiveRequests, cordnJoinRequests.length],
);
// Crew items that count toward the BADGE = ones the user hasn't acknowledged
// yet (newer than the last "Mark read"/open). This lets "Mark read" clear the
// badge while the actionable cards stay visible in their tab.
const unreadCrew = useMemo(() => {
const ts = (n: { createdAt?: number }) => n.createdAt ?? 0;
let count = 0;
for (const inv of invites) if (ts(inv) > seenAt) count++;
for (const crew of listedCrews) if (ts(crew) > seenAt) count++;
for (const { request } of joinRequests) if (ts(request) > seenAt) count++;
for (const req of collectiveRequests) if (ts(req) > seenAt) count++;
for (const req of cordnJoinRequests) if (req.at > seenAt) count++;
return count;
}, [invites, listedCrews, joinRequests, collectiveRequests, cordnJoinRequests, seenAt]);
// Unread social items = activity newer than the last time the bell was opened.
const unreadActivity = useMemo(
() => activity.filter((n) => n.createdAt > lastRead).length,
[activity, lastRead],
);
// Trades are actionable like crew items — they count until acknowledged.
const unreadTrades = useMemo(
() => tradeNotifs.filter((t) => t.createdAt > seenAt).length,
[tradeNotifs, seenAt],
);
// Unread newsletter issues (only counts for subscribers).
const unreadNewsletter = useMemo(
() =>
isSubscribed
? newsletterIssues.filter((i) => i.publishedAt > newsletterLastRead).length
: 0,
[isSubscribed, newsletterIssues, newsletterLastRead],
);
// Badge = only UNACKNOWLEDGED items, so "Mark read" actually clears it.
const badgeTotal = unreadCrew + unreadActivity + unreadNewsletter + unreadTrades + unreadDms;
// Opening the bell marks social + crew as read (and newsletter, since the
// News tab is right there). Newsletter read state also clears on the /newsletter page.
useEffect(() => {
if (open) {
markAllRead();
markNewsletterRead();
}
}, [open, markAllRead, markNewsletterRead]);
const isLoading = crewLoading || activityLoading || tradeLoading || cordnJoinLoading;
if (!user) return null;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'group relative text-gray-300 transition-colors hover:text-[#ccff00] hover:bg-[#ccff00]/10',
badgeTotal > 0 && 'text-[#ccff00]',
className,
)}
title={t('socialUi.bell.notifications')}
aria-label={badgeTotal > 0 ? t('socialUi.bell.notificationsNew', { count: badgeTotal }) : t('socialUi.bell.notifications')}
>
{/* Soft glow behind the bell when there's something waiting */}
{badgeTotal > 0 && (
<span
aria-hidden
className="absolute inset-0 rounded-full bg-[#ccff00]/20 blur-md animate-pulse"
/>
)}
<Bell
className={cn(
'relative h-5 w-5 transition-transform',
badgeTotal > 0 && 'animate-[wiggle_1.5s_ease-in-out_infinite] origin-top',
)}
/>
{badgeTotal > 0 && (
<span
aria-live="polite"
aria-label={t('socialUi.bell.unreadCount', { count: badgeTotal })}
className="absolute -right-1 -top-1 flex min-w-[18px] items-center justify-center rounded-full bg-[#ccff00] px-1 text-[10px] font-black leading-[18px] text-black shadow-[0_0_8px_rgba(204,255,0,0.6)] ring-2 ring-black"
>
{badgeTotal > 9 ? '9+' : badgeTotal}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
collisionPadding={8}
className="flex max-h-[min(70vh,var(--radix-popover-content-available-height))] w-[380px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden p-0"
sideOffset={8}
>
<div className="flex shrink-0 items-center justify-between border-b border-white/10 bg-gradient-to-r from-teal-500/[0.08] via-transparent to-[#ccff00]/[0.06] px-4 py-3">
<h3 className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white">
<span className="relative flex h-6 w-6 items-center justify-center rounded-lg bg-teal-500/15">
<Bell className="h-3.5 w-3.5 text-teal-400" />
</span>
{t('socialUi.bell.notifications')}
{badgeTotal > 0 && (
<span className="rounded-full bg-[#ccff00] px-1.5 text-[10px] font-black leading-4 text-black">
{badgeTotal > 99 ? '99+' : badgeTotal}
</span>
)}
</h3>
{badgeTotal > 0 ? (
<button
type="button"
onClick={() => { markAllRead(); markDmEnvelopesSeen(); }}
className="flex items-center gap-1 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-black uppercase tracking-wide text-gray-300 transition-all active:scale-95 hover:border-teal-400/40 hover:bg-teal-500/10 hover:text-teal-300"
>
<CheckCheck className="h-3 w-3" /> {t('socialUi.bell.markRead')}
</button>
) : (
<span className="text-[10px] font-bold uppercase tracking-wide text-gray-600">
{t('socialUi.bell.allClear')}
</span>
)}
</div>
<Tabs defaultValue="all" className="flex min-h-0 w-full flex-1 flex-col">
<TabsList className="grid w-full shrink-0 grid-cols-5 rounded-none border-b border-white/10 bg-transparent p-0">
<TabsTrigger
value="all"
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
>
{t('socialUi.bell.tabAll')}
{badgeTotal > 0 && <span className="ml-1 text-teal-400">{badgeTotal}</span>}
</TabsTrigger>
<TabsTrigger
value="trades"
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-[#ff6600] data-[state=active]:bg-transparent data-[state=active]:text-[#ff6600]"
>
<Handshake className="mr-1 h-3 w-3" /> {t('socialUi.bell.tabTrades')}
{tradeNotifs.length > 0 && <span className="ml-1 text-[#ff6600]">{tradeNotifs.length}</span>}
</TabsTrigger>
<TabsTrigger
value="social"
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
>
<Heart className="mr-1 h-3 w-3" /> {t('socialUi.bell.tabSocial')}
</TabsTrigger>
<TabsTrigger
value="news"
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-[#ccff00] data-[state=active]:bg-transparent data-[state=active]:text-[#ccff00]"
>
<Radio className="mr-1 h-3 w-3" /> {t('socialUi.bell.tabNews')}
{unreadNewsletter > 0 && <span className="ml-1 text-[#ccff00]">{unreadNewsletter}</span>}
</TabsTrigger>
<TabsTrigger
value="crews"
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
>
<Users className="mr-1 h-3 w-3" /> {t('socialUi.bell.tabCrews')}
{crewTotal > 0 && <span className="ml-1 text-teal-400">{crewTotal}</span>}
</TabsTrigger>
</TabsList>
<ScrollArea className="min-h-0 flex-1">
{/* ─── ALL ─────────────────────────────────────────────────── */}
<TabsContent value="all" className="m-0 p-3">
{isLoading && badgeTotal === 0 && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingUpdates')}
</div>
)}
{!isLoading && crewTotal === 0 && activity.length === 0 && newsletterIssues.length === 0 && tradeNotifs.length === 0 && unreadDms === 0 && (
<EmptyState label={t('socialUi.bell.emptyAll')} />
)}
<div className="space-y-2.5">
{unreadDms > 0 && (
<Link
to="/messages"
onClick={() => setOpen(false)}
className="flex items-center gap-3 rounded-xl border border-[#ff0040]/30 bg-[#ff0040]/[0.06] p-3 transition-colors hover:border-[#ff0040]/60 hover:bg-[#ff0040]/10"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#ff0040]/15">
<MessageCircle className="h-4 w-4 text-[#ff0040]" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-black text-white">{t('socialUi.bell.messagesAndCrews')}</p>
<p className="text-xs text-gray-400">{t('socialUi.bell.dmsUnread', { count: unreadDms })}</p>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-[#ff0040]" />
</Link>
)}
{/* Buying/selling steps first — they're the most time-sensitive. */}
{tradeNotifs.map((n) => (
<TradeNotificationCard
key={n.id}
notification={n}
unread={n.createdAt > seenAt}
onNavigate={() => setOpen(false)}
/>
))}
{joinRequests.map(({ request, crew, endorsements }) => (
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
))}
{cordnJoinRequests.map((notice) => (
<CordnJoinRequestCard
key={notice.id}
notice={notice}
onDismiss={dismissCordnJoin}
onNavigate={() => setOpen(false)}
/>
))}
{collectiveRequests.map((request) => (
<CollectiveJoinRequestCard key={request.id} request={request} compact />
))}
{invites.map((invite) => (
<CrewInviteCard key={invite.id} invite={invite} compact />
))}
{listedCrews.map((crew) => (
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
))}
{/* Latest newsletter drop surfaces in All when unread (subscribers). */}
{isSubscribed && newsletterIssues[0] && newsletterIssues[0].publishedAt > newsletterLastRead && (
<NewsletterItem
key={newsletterIssues[0].id}
issue={newsletterIssues[0]}
unread
onNavigate={() => setOpen(false)}
/>
)}
{activity.map((n) => (
<ActivityNotificationCard
key={n.id}
notification={n}
unread={n.createdAt > lastRead}
onNavigate={() => setOpen(false)}
/>
))}
</div>
</TabsContent>
{/* ─── TRADES (offers, counter-offers, confirmations) ──────── */}
<TabsContent value="trades" className="m-0 p-3">
{tradeLoading && tradeNotifs.length === 0 && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingTrades')}
</div>
)}
{!tradeLoading && tradeNotifs.length === 0 && (
<EmptyState label={t('socialUi.bell.emptyTrades')} />
)}
<div className="space-y-2.5">
{tradeNotifs.map((n) => (
<TradeNotificationCard
key={n.id}
notification={n}
unread={n.createdAt > seenAt}
onNavigate={() => setOpen(false)}
/>
))}
</div>
</TabsContent>
{/* ─── SOCIAL ──────────────────────────────────────────────── */}
<TabsContent value="social" className="m-0 p-3">
{activityLoading && activity.length === 0 && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingUpdates')}
</div>
)}
{!activityLoading && activity.length === 0 && (
<EmptyState label={t('socialUi.bell.emptySocial')} />
)}
<div className="space-y-2.5">
{activity.map((n) => (
<ActivityNotificationCard
key={n.id}
notification={n}
unread={n.createdAt > lastRead}
onNavigate={() => setOpen(false)}
/>
))}
</div>
</TabsContent>
{/* ─── NEWS (newsletter mailbox) ───────────────────────────── */}
<TabsContent value="news" className="m-0 p-3">
{newsletterLoading && newsletterIssues.length === 0 && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.loadingDrops')}
</div>
)}
{!newsletterLoading && newsletterIssues.length === 0 && (
<EmptyState label={t('socialUi.bell.emptyNews')} />
)}
{!isSubscribed && newsletterIssues.length > 0 && (
<Link
to="/newsletter"
onClick={() => setOpen(false)}
className="mb-2.5 flex items-center gap-2 rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/[0.06] p-3 text-xs font-bold text-[#ccff00] transition-colors hover:bg-[#ccff00]/10"
>
<Radio className="h-4 w-4 flex-shrink-0" />
<span className="flex-1">{t('socialUi.bell.subscribeBanner')}</span>
<ArrowRight className="h-3.5 w-3.5 flex-shrink-0" />
</Link>
)}
<div className="space-y-2.5">
{newsletterIssues.map((issue) => (
<NewsletterItem
key={issue.id}
issue={issue}
unread={isSubscribed && issue.publishedAt > newsletterLastRead}
onNavigate={() => setOpen(false)}
/>
))}
</div>
</TabsContent>
{/* ─── CREWS ───────────────────────────────────────────────── */}
<TabsContent value="crews" className="m-0 p-3">
{crewLoading && crewTotal === 0 && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingUpdates')}
</div>
)}
{!crewLoading && crewTotal === 0 && (
<EmptyState label={t('socialUi.bell.emptyCrews')} />
)}
<div className="space-y-2.5">
{joinRequests.map(({ request, crew, endorsements }) => (
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
))}
{cordnJoinRequests.map((notice) => (
<CordnJoinRequestCard
key={notice.id}
notice={notice}
onDismiss={dismissCordnJoin}
onNavigate={() => setOpen(false)}
/>
))}
{collectiveRequests.map((request) => (
<CollectiveJoinRequestCard key={request.id} request={request} compact />
))}
{invites.map((invite) => (
<CrewInviteCard key={invite.id} invite={invite} compact />
))}
{listedCrews.map((crew) => (
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
))}
</div>
</TabsContent>
</ScrollArea>
</Tabs>
</PopoverContent>
</Popover>
);
}