- New src/lib/dateFnsI18n.ts re-exports date-fns with formatDistanceToNow /
formatDistanceToNowStrict / formatDistance / format defaulting to the active
locale, plus dateLocaleCode() for native toLocale{Date,Time}String calls.
- LanguageProvider keeps the locale in sync during render, so the first paint is
already in-language.
- Codemod: 11 date-fns imports now use the shim; ~39 native date/time calls pass
the app locale code instead of undefined/[]/hardcoded 'en-US'. Number
.toLocaleString() calls are deliberately untouched.
553 lines
25 KiB
TypeScript
553 lines
25 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 } 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 { 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 { 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 }: { className?: string }) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
|
|
useCrewNotifications();
|
|
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
|
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,
|
|
[invites, listedCrews, joinRequests, collectiveRequests],
|
|
);
|
|
|
|
// 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++;
|
|
return count;
|
|
}, [invites, listedCrews, joinRequests, collectiveRequests, 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;
|
|
|
|
// 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;
|
|
|
|
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}
|
|
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 && (
|
|
<EmptyState label={t('socialUi.bell.emptyAll')} />
|
|
)}
|
|
<div className="space-y-2.5">
|
|
{/* 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 />
|
|
))}
|
|
{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 />
|
|
))}
|
|
{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>
|
|
);
|
|
}
|