Files
eranos/src/components/profile/ProfileIdentityRail.tsx
T
2026-06-12 12:55:00 -05:00

929 lines
32 KiB
TypeScript

import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
Bitcoin,
Globe,
HandHeart,
Megaphone,
MoreHorizontal,
QrCode,
Users,
} from 'lucide-react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Skeleton } from '@/components/ui/skeleton';
import { BioContent } from '@/components/BioContent';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard';
import { EmojifiedText } from '@/components/CustomEmoji';
import { FollowToggleButton } from '@/components/FollowButton';
import { Nip05Badge } from '@/components/Nip05Badge';
import { PledgeCard } from '@/components/PledgeCard';
import { ProfileReactionButton } from '@/components/ProfileReactionButton';
import { OrganizationsAllDialog } from '@/components/profile/OrganizationsAllDialog';
import { ProfileVerifierSection } from '@/components/profile/ProfileVerifierSection';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useProfileOrganizations, type ProfileOrganization } from '@/hooks/useProfileOrganizations';
import type { ProfileCampaignStats } from '@/hooks/useProfileCampaignStats';
import type { ParsedCampaign } from '@/lib/campaign';
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
import { formatNumber } from '@/lib/formatNumber';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import type { Action } from '@/hooks/useActions';
interface ProfileIdentityRailProps {
pubkey: string;
/** Whether the logged-in user is viewing their own profile. */
isOwnProfile: boolean;
/** Resolved kind 0 metadata, if any. */
metadata: NostrMetadata | undefined;
/** Raw kind 0 event — needed for emoji tag rendering on display name. */
metadataEvent: NostrEvent | undefined;
/** Pre-resolved display name (with `genUserName` fallback applied upstream). */
displayName: string;
/** True while the kind-0 author query is still in flight; renders skeletons. */
isAuthorLoading: boolean;
/** Banner image URL — used to wire the avatar lightbox to the same url. */
bannerUrl: string | undefined;
/** Optional NIP-38 status (renders as a thought bubble next to the avatar). */
status?: { text: string | undefined; url: string | undefined };
/** Custom kind-0 profile fields, already parsed. */
fields: { label: string; value: string }[];
/** Pre-rendered list of <ProfileFieldInline /> nodes — keeps that helper inside ProfilePage. */
fieldsContent: ReactNode;
/** Campaigns authored by this profile (newest-first). */
campaigns: ParsedCampaign[];
/** Aggregated campaign + raised stats for the stat block. */
campaignStats: ProfileCampaignStats;
/**
* The profile's pledges (kind 36639) — used to surface the latest one
* in the rail when the profile has no campaigns. The rail picks the
* newest by `createdAt` itself, so callers can pass the unsorted list.
*/
pledges: Action[];
/** Spot BTC price for the Raised stat row. */
btcPrice: number | undefined;
followersCount: number;
followingCount: number;
isFollowing: boolean;
followPending: boolean;
onLightbox: (url: string) => void;
onFollowersOpen: () => void;
onFollowingOpen: () => void;
onMoreMenuOpen: () => void;
onFollowQROpen: () => void;
onToggleFollow: () => void;
onTabChange: (tabId: string) => void;
onDonate: (campaign: ParsedCampaign) => void;
/** Whether the viewer can take any action (logged in). Disables follow when null. */
canFollow: boolean;
/** Latest kind-0 event used by ProfileReactionButton; falls back to metadataEvent. */
authorEvent: NostrEvent | undefined;
}
const RAIL_CAMPAIGN_LIMIT = 2;
const RAIL_ORG_LIMIT = 4;
/**
* ProfileIdentityRail — the left rail of the two-column profile.
*
* Holds everything that's a *standing fact* about the profile: who they
* are (avatar, name, bio), what they're raising for (active campaigns),
* who they organize with (orgs), key counts (followers / following /
* campaigns / pledges / raised), and the freeform profile fields.
*
* Sticky on `lg+` so it stays visible while the right tab column scrolls.
* Below `lg` the rail just stacks above the content — its avatar still
* overlaps the banner via `-mt-16` because the rail is the first child
* below the banner element.
*
* The rail does NOT own the tab bar or the tab content — those live in
* the right column. Click handlers like `onTabChange` exist so rail rows
* can switch tabs (e.g. "See all campaigns →" jumps to the Campaigns tab).
*/
export function ProfileIdentityRail({
pubkey,
isOwnProfile,
metadata,
metadataEvent,
displayName,
isAuthorLoading,
bannerUrl: _bannerUrl,
status,
fields,
fieldsContent,
campaigns,
campaignStats,
pledges,
btcPrice,
followersCount,
followingCount,
isFollowing,
followPending,
onLightbox,
onFollowersOpen,
onFollowingOpen,
onMoreMenuOpen,
onFollowQROpen,
onToggleFollow,
onTabChange,
onDonate,
canFollow,
authorEvent,
}: ProfileIdentityRailProps) {
if (isAuthorLoading) {
return (
<RailSkeleton />
);
}
const websiteHref = (() => {
if (!metadata?.website) return undefined;
const candidate = metadata.website.startsWith('http')
? metadata.website
: `https://${metadata.website}`;
return sanitizeUrl(candidate);
})();
const onchainCampaigns = campaigns.filter((c) => !!c.wallets?.onchain);
return (
// Two-layer structure so the rail can scroll independently on lg+
// without clipping the avatar that pokes above the rail's top edge:
// - Outer flex column owns the avatar (which uses -mt-16 to overlap
// the banner). It must NOT clip overflow.
// - Inner div carries the rest of the rail and is the scroll
// container: `lg:flex-1 lg:min-h-0 lg:overflow-y-auto` makes it
// fill the remaining height of the sticky aside and scroll
// internally so the page's main scroll only drives the feed.
<div className="flex flex-col h-full">
{/* Avatar — overlaps the banner from inside the rail. Sits OUTSIDE
the scroll container so its negative-margin overhang is never
clipped by `overflow-y-auto`. */}
<ProfileAvatarBlock
metadata={metadata}
displayName={displayName}
status={status}
onLightbox={onLightbox}
/>
<div className="flex flex-col gap-5 mt-5 lg:flex-1 lg:min-h-0 lg:overflow-y-auto pb-4">
<ProfileIdentityHeader
pubkey={pubkey}
isOwnProfile={isOwnProfile}
metadata={metadata}
metadataEvent={metadataEvent}
displayName={displayName}
websiteHref={websiteHref}
isFollowing={isFollowing}
followPending={followPending}
canFollow={canFollow}
followersCount={followersCount}
followingCount={followingCount}
totalRaisedSats={campaignStats.totalRaisedSats}
btcPrice={btcPrice}
onchainCampaigns={onchainCampaigns}
onToggleFollow={onToggleFollow}
onMoreMenuOpen={onMoreMenuOpen}
onFollowQROpen={onFollowQROpen}
onDonate={onDonate}
onFollowersOpen={onFollowersOpen}
onFollowingOpen={onFollowingOpen}
onTabChange={onTabChange}
authorEvent={authorEvent}
/>
<ProfileOverviewSections
pubkey={pubkey}
isOwnProfile={isOwnProfile}
campaigns={campaigns}
campaignStats={campaignStats}
pledges={pledges}
btcPrice={btcPrice}
fields={fields}
fieldsContent={fieldsContent}
onTabChange={onTabChange}
/>
</div>
</div>
);
}
// ─── Identity header (name / bio / actions / stats) ─────────────────────────
interface ProfileIdentityHeaderProps {
pubkey: string;
isOwnProfile: boolean;
metadata: NostrMetadata | undefined;
metadataEvent: NostrEvent | undefined;
displayName: string;
/** Pre-sanitized website URL (`undefined` if none / unsafe). */
websiteHref: string | undefined;
isFollowing: boolean;
followPending: boolean;
canFollow: boolean;
followersCount: number;
followingCount: number;
totalRaisedSats: number;
btcPrice: number | undefined;
onchainCampaigns: ParsedCampaign[];
onToggleFollow: () => void;
onMoreMenuOpen: () => void;
onFollowQROpen: () => void;
onDonate: (campaign: ParsedCampaign) => void;
onFollowersOpen: () => void;
onFollowingOpen: () => void;
onTabChange: (tabId: string) => void;
authorEvent: NostrEvent | undefined;
className?: string;
}
/**
* The fixed identity block: name, NIP-05, website, bio, action bar, and
* top-level stat row (Followers / Following / Raised).
*
* Rendered inside `ProfileIdentityRail` on desktop and directly above the
* tab bar on mobile. Does NOT include the avatar — that lives outside any
* scroll container so its `-mt-16` overhang into the banner isn't clipped.
*/
export function ProfileIdentityHeader({
pubkey,
isOwnProfile,
metadata,
metadataEvent,
displayName,
websiteHref,
isFollowing,
followPending,
canFollow,
followersCount,
followingCount,
totalRaisedSats,
btcPrice,
onchainCampaigns,
onToggleFollow,
onMoreMenuOpen,
onFollowQROpen,
onDonate,
onFollowersOpen,
onFollowingOpen,
onTabChange,
authorEvent,
className,
}: ProfileIdentityHeaderProps) {
return (
<div className={cn('flex flex-col gap-5', className)}>
{/* Identity: name + NIP-05 + website + bio */}
<div className="space-y-1.5">
<h1 className="text-xl font-bold leading-tight break-words">
{metadataEvent ? (
<EmojifiedText tags={metadataEvent.tags}>{displayName}</EmojifiedText>
) : displayName}
</h1>
{metadata?.nip05 && (
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey} className="text-sm text-muted-foreground" />
)}
{websiteHref && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground min-w-0">
<Globe className="size-3.5 shrink-0" />
<a
href={websiteHref}
target="_blank"
rel="noopener noreferrer"
className="truncate text-primary hover:underline"
>
{metadata!.website!.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</a>
</div>
)}
{metadata?.about && (
<p className="pt-1 text-sm whitespace-pre-wrap break-words text-foreground/90">
<BioContent tags={metadataEvent?.tags}>{metadata.about}</BioContent>
</p>
)}
</div>
{/* Action bar — wraps onto multiple rows in a 340px-wide rail. */}
<ActionBar
isOwnProfile={isOwnProfile}
isFollowing={isFollowing}
followPending={followPending}
canFollow={canFollow}
onToggleFollow={onToggleFollow}
onMoreMenuOpen={onMoreMenuOpen}
onFollowQROpen={onFollowQROpen}
authorEvent={authorEvent}
onchainCampaigns={onchainCampaigns}
onDonate={onDonate}
/>
{/* Stats: Followers + Following inline; Raised below if applicable. */}
<StatList
followersCount={followersCount}
followingCount={followingCount}
totalRaisedSats={totalRaisedSats}
btcPrice={btcPrice}
onFollowersOpen={onFollowersOpen}
onFollowingOpen={onFollowingOpen}
onTabChange={onTabChange}
/>
</div>
);
}
// ─── Overview sections (campaigns / latest pledge / orgs / fields) ──────────
interface ProfileOverviewSectionsProps {
pubkey: string;
isOwnProfile: boolean;
campaigns: ParsedCampaign[];
campaignStats: ProfileCampaignStats;
pledges: Action[];
btcPrice: number | undefined;
fields: { label: string; value: string }[];
fieldsContent: ReactNode;
onTabChange: (tabId: string) => void;
/** Render the Organizations grid inline (default true). Set false on
* mobile when "Community" is a dedicated tab and orgs should not also
* appear inside Overview. */
showOrganizations?: boolean;
className?: string;
}
/**
* The collection of secondary rail sections: active campaigns, a fallback
* "latest pledge" card when there are no campaigns, organizations the
* profile founded/moderates, and freeform kind-0 profile fields.
*
* On desktop these stack inside the identity rail. On mobile they become
* the content of the "Overview" tab (with `showOrganizations={false}` so
* the organizations list moves into the dedicated "Community" tab).
*/
export function ProfileOverviewSections({
pubkey,
isOwnProfile,
campaigns,
campaignStats,
pledges,
btcPrice,
fields,
fieldsContent,
onTabChange,
showOrganizations = true,
className,
}: ProfileOverviewSectionsProps) {
const { t } = useTranslation();
return (
<div className={cn('flex flex-col gap-5', className)}>
{/* Verifier statement (kind 14672) — surfaced first so donors can
immediately see how this account verifies campaigns. Renders
nothing when the profile has not published a statement. */}
<ProfileVerifierSection pubkey={pubkey} />
{/* Profile fields (rendered upstream) — placed first so the
profile's own freeform metadata (links, addresses, etc.) is
the first thing visitors read, ahead of campaigns/orgs. */}
{fields.length > 0 && (
<section className="space-y-3">
<RailSectionHeader icon={null} title={t('profile.sections.profile')} />
<div className="space-y-3">{fieldsContent}</div>
</section>
)}
{/* Active campaigns */}
<RailCampaignsSection
campaigns={campaigns}
isOwnProfile={isOwnProfile}
isLoading={campaignStats.isVerifying && campaigns.length === 0}
onSeeAll={() => onTabChange('campaigns')}
/>
{/* Latest pledge — surfaced as a fallback when this profile has
nothing in the Campaigns slot, so the rail still has a piece of
first-class Agora content in the campaigns slot. */}
{campaigns.length === 0 && pledges.length > 0 && (
<RailLatestPledgeSection
pledges={pledges}
btcPrice={btcPrice}
showSeeAll={pledges.length > 1}
onSeeAll={() => onTabChange('pledges')}
/>
)}
{/* Organizations */}
{showOrganizations && <RailOrganizationsSection pubkey={pubkey} />}
</div>
);
}
/**
* Standalone organizations section — same `RailOrganizationsSection`
* content used inside the rail's overview, but exposed as a top-level
* export so the mobile "Community" tab can render it directly.
*
* The rendering is identical to the rail's version (same grid, same
* "See all" overflow dialog). Wrapping it in its own export keeps the
* tab content honest about where the data is coming from and lets us
* swap in a richer layout later without touching ProfilePage.
*/
export function ProfileOrganizationsSection({ pubkey, className }: { pubkey: string; className?: string }) {
return (
<div className={cn('flex flex-col gap-5', className)}>
<RailOrganizationsSection pubkey={pubkey} />
</div>
);
}
// ─── Avatar block ────────────────────────────────────────────────────────────
interface ProfileAvatarBlockProps {
metadata: NostrMetadata | undefined;
displayName: string;
status: { text: string | undefined; url: string | undefined } | undefined;
onLightbox: (url: string) => void;
}
/**
* Avatar + NIP-38 status bubble. Always rendered as the first thing below
* the banner; the avatar uses `-mt-16 md:-mt-20` to overlap into the banner
* area. Must NOT be wrapped in any element with `overflow: hidden` /
* `overflow-y: auto` or the overhang will be clipped.
*/
export function ProfileAvatarBlock({
metadata,
displayName,
status,
onLightbox,
}: ProfileAvatarBlockProps) {
const picture = metadata?.picture;
return (
<div className="relative">
<button
className="relative z-10 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-full -mt-16 md:-mt-20 block"
onClick={() => picture && onLightbox(picture)}
disabled={!picture}
>
<Avatar className={cn(
'size-28 md:size-32 border-4 border-background shadow-lg',
picture && 'cursor-pointer',
)}>
<AvatarImage src={picture} alt={displayName} proxyWidth={256} />
<AvatarFallback className="bg-primary/20 text-primary text-3xl">
{displayName[0]?.toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
</button>
{/* NIP-38 thought bubble — floats to the right of the avatar over the banner area. */}
{status?.text && (
<div className="absolute top-2 left-[calc(7rem+8px)] md:left-[calc(8rem+8px)] z-10 max-w-[200px] animate-in fade-in slide-in-from-left-1 duration-300">
<div className="relative bg-background/90 backdrop-blur-sm border border-border rounded-xl px-3 py-1.5 shadow-lg">
<p className="text-xs text-foreground italic truncate">
{status.url ? (
<a href={status.url} target="_blank" rel="noopener noreferrer" className="hover:underline">
{status.text}
</a>
) : (
status.text
)}
</p>
{/* Speech bubble triangle tail */}
<div className="absolute -bottom-[7px] left-1 size-0 border-t-[8px] border-t-border border-r-[8px] border-r-transparent" />
<div className="absolute -bottom-[5.5px] left-1 size-0 border-t-[7px] border-t-background border-r-[7px] border-r-transparent" />
</div>
</div>
)}
</div>
);
}
// ─── Action bar ──────────────────────────────────────────────────────────────
function ActionBar({
isOwnProfile,
isFollowing,
followPending,
canFollow,
onToggleFollow,
onMoreMenuOpen,
onFollowQROpen,
authorEvent,
onchainCampaigns,
onDonate,
}: {
isOwnProfile: boolean;
isFollowing: boolean;
followPending: boolean;
canFollow: boolean;
onToggleFollow: () => void;
onMoreMenuOpen: () => void;
onFollowQROpen: () => void;
authorEvent: NostrEvent | undefined;
onchainCampaigns: ParsedCampaign[];
onDonate: (campaign: ParsedCampaign) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-2">
{isOwnProfile ? (
<>
<Link to="/settings/profile" className="flex-1 min-w-[140px]">
<Button variant="outline" className="rounded-full font-bold w-full">
{t('profile.header.editProfile')}
</Button>
</Link>
<Button
variant="outline"
size="icon"
className="rounded-full size-10"
title={t('profile.header.shareFollowLink')}
onClick={onFollowQROpen}
>
<QrCode className="size-5" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full size-10"
onClick={onMoreMenuOpen}
title={t('profile.header.moreOptions')}
>
<MoreHorizontal className="size-5" />
</Button>
</>
) : (
<>
<FollowToggleButton
isFollowing={isFollowing}
isPending={followPending}
onClick={onToggleFollow}
disabled={!canFollow}
/>
{onchainCampaigns.length === 1 ? (
<Button
onClick={() => onDonate(onchainCampaigns[0])}
className="rounded-full font-bold gap-1.5"
>
<HandHeart className="size-4" />
{t('profile.header.donate')}
</Button>
) : onchainCampaigns.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="rounded-full font-bold gap-1.5">
<HandHeart className="size-4" />
{t('profile.header.donate')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{onchainCampaigns.map((c) => (
<DropdownMenuItem
key={c.aTag}
onClick={() => onDonate(c)}
className="flex flex-col items-start gap-0.5"
>
<span className="font-medium truncate w-full">{c.title}</span>
{c.goalUsd ? (
<span className="text-xs text-muted-foreground">
{t('profile.header.campaignGoal', { amount: c.goalUsd.toLocaleString() })}
</span>
) : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{authorEvent && <ProfileReactionButton profileEvent={authorEvent} />}
<Button
variant="outline"
size="icon"
className="rounded-full size-10"
onClick={onMoreMenuOpen}
title={t('profile.header.moreOptions')}
>
<MoreHorizontal className="size-5" />
</Button>
</>
)}
</div>
);
}
// ─── Stat list ──────────────────────────────────────────────────────────────
function StatList({
followersCount,
followingCount,
totalRaisedSats,
btcPrice,
onFollowersOpen,
onFollowingOpen,
onTabChange,
}: {
followersCount: number;
followingCount: number;
totalRaisedSats: number;
btcPrice: number | undefined;
onFollowersOpen: () => void;
onFollowingOpen: () => void;
onTabChange: (id: string) => void;
}) {
const { t } = useTranslation();
// Secondary stat rows (one per row). Followers / Following live inline
// at the top. Campaigns and Pledges are intentionally not surfaced as
// counts here — the rail's Campaigns and (when relevant) Pledges
// sections below already show the underlying content directly.
const rows: Array<{
icon?: ReactNode;
label: string;
value: string;
onClick?: () => void;
show: boolean;
}> = [
{
icon: <Bitcoin className="size-3.5 text-primary" />,
label: t('profile.stats.raised'),
value: formatCampaignAmount(totalRaisedSats, btcPrice),
onClick: () => onTabChange('campaigns'),
show: totalRaisedSats > 0,
},
].filter((r) => r.show);
const hasFollowRow = followersCount > 0 || followingCount > 0;
if (!hasFollowRow && rows.length === 0) return null;
return (
<div className="space-y-2">
{/* Followers + Following on a single horizontal row. */}
{hasFollowRow && (
<div className="flex items-center gap-5 text-sm">
{followersCount > 0 && (
<button
onClick={onFollowersOpen}
className="flex items-baseline gap-1.5 hover:opacity-80 transition-opacity"
title={t('profile.stats.followersTitle', { count: followersCount })}
>
<span className="font-bold tabular-nums text-foreground">{formatNumber(followersCount)}</span>
<span className="text-muted-foreground">{t('profile.stats.followers')}</span>
</button>
)}
{followingCount > 0 && (
<button
onClick={onFollowingOpen}
className="flex items-baseline gap-1.5 hover:opacity-80 transition-opacity"
title={t('profile.stats.followingTitle', { count: followingCount })}
>
<span className="font-bold tabular-nums text-foreground">{formatNumber(followingCount)}</span>
<span className="text-muted-foreground">{t('profile.stats.following')}</span>
</button>
)}
</div>
)}
{/* Secondary stat rows. */}
{rows.length > 0 && (
<div className="rounded-xl border border-border/60 bg-card/40 divide-y divide-border/60">
{rows.map((row) => (
<button
key={row.label}
onClick={row.onClick}
className="w-full flex items-center justify-between gap-3 px-3 py-2 text-sm hover:bg-secondary/40 transition-colors first:rounded-t-xl last:rounded-b-xl"
>
<span className="flex items-center gap-2 text-muted-foreground">
{row.icon}
{row.label}
</span>
<span className="font-semibold tabular-nums text-foreground">{row.value}</span>
</button>
))}
</div>
)}
</div>
);
}
// ─── Rail Campaigns Section ─────────────────────────────────────────────────
function RailCampaignsSection({
campaigns,
isOwnProfile,
isLoading,
onSeeAll,
}: {
campaigns: ParsedCampaign[];
isOwnProfile: boolean;
isLoading: boolean;
onSeeAll: () => void;
}) {
const { t } = useTranslation();
const { data: moderation } = useCampaignModeration();
const visible = isOwnProfile
? campaigns
: campaigns.filter((c) => !moderation.hiddenCoords.has(c.aTag));
if (isLoading && visible.length === 0) {
return (
<section className="space-y-3">
<RailSectionHeader icon={<Megaphone className="size-4 text-primary" />} title={t('profile.sections.campaigns')} />
<CampaignCardSkeleton />
</section>
);
}
if (visible.length === 0) return null;
const shown = visible.slice(0, RAIL_CAMPAIGN_LIMIT);
const more = visible.length - shown.length;
return (
<section className="space-y-3">
<RailSectionHeader
icon={<Megaphone className="size-4 text-primary" />}
title={t('profile.sections.campaigns')}
count={visible.length}
/>
<div className="space-y-3">
{shown.map((c) => (
<CampaignCard key={c.aTag} campaign={c} />
))}
</div>
{(more > 0 || visible.length > 1) && (
<button
type="button"
onClick={onSeeAll}
className="text-sm text-primary hover:underline font-medium"
>
{more > 0 ? t('profile.sections.seeAllCampaigns', { count: visible.length }) : t('profile.sections.viewCampaignsTab')}
</button>
)}
</section>
);
}
// ─── Rail Latest Pledge Section ─────────────────────────────────────────────
/**
* Compact "latest pledge" card shown in the rail when the profile has
* no campaigns. Picks the newest pledge from the supplied list (sorted
* by `createdAt` descending) and renders it as a single small card with
* cover, title, pledged amount, country, and deadline.
*/
function RailLatestPledgeSection({
pledges,
btcPrice,
showSeeAll,
onSeeAll,
}: {
pledges: Action[];
btcPrice: number | undefined;
showSeeAll: boolean;
onSeeAll: () => void;
}) {
const { t } = useTranslation();
// Pick the newest pledge by created_at. The page query is roughly
// newest-first already, but sorting here keeps the rail correct
// regardless of upstream order.
const latest = [...pledges].sort((a, b) => b.createdAt - a.createdAt)[0];
if (!latest) return null;
return (
<section className="space-y-3">
<RailSectionHeader
icon={<HandHeart className="size-4 text-primary" />}
title={t('profile.sections.latestPledge')}
/>
<PledgeCard action={latest} btcPrice={btcPrice} variant="rail" />
{showSeeAll && (
<button
type="button"
onClick={onSeeAll}
className="text-sm text-primary hover:underline font-medium"
>
{t('profile.sections.seeAllPledges', { count: pledges.length })}
</button>
)}
</section>
);
}
// ─── Rail Organizations Section ─────────────────────────────────────────────
function RailOrganizationsSection({ pubkey }: { pubkey: string }) {
const { t } = useTranslation();
const { data: orgs, isLoading } = useProfileOrganizations(pubkey);
if (isLoading && orgs.length === 0) {
return (
<section className="space-y-3">
<RailSectionHeader icon={<Users className="size-4 text-primary" />} title={t('profile.sections.groups')} />
<div className="grid grid-cols-2 gap-3">
{Array.from({ length: 2 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</div>
</section>
);
}
if (orgs.length === 0) return null;
const shown = orgs.slice(0, RAIL_ORG_LIMIT);
const overflow = Math.max(0, orgs.length - shown.length);
return (
<section className="space-y-3">
<RailSectionHeader
icon={<Users className="size-4 text-primary" />}
title={t('profile.sections.groups')}
count={orgs.length}
/>
<div className="grid grid-cols-2 gap-3">
{shown.map((entry) => (
<RailOrgCell key={entry.community.aTag} entry={entry} />
))}
</div>
{overflow > 0 && (
<OrganizationsAllDialog orgs={orgs}>
<button
type="button"
className="text-sm text-primary hover:underline font-medium"
>
{t('profile.sections.seeAllGroups', { count: orgs.length })}
</button>
</OrganizationsAllDialog>
)}
</section>
);
}
function RailOrgCell({ entry }: { entry: ProfileOrganization }) {
const { t } = useTranslation();
return (
<div className="relative">
<CommunityMiniCard community={entry.community} className="w-full" />
<Badge
variant="secondary"
className={cn(
'absolute top-2 left-2 backdrop-blur bg-background/90 border-border/40 text-[9px] font-semibold uppercase tracking-wide px-1.5 py-0.5',
entry.isFounder ? 'text-primary' : 'text-foreground',
)}
>
{entry.isFounder ? t('profile.badges.founder') : t('profile.badges.mod')}
</Badge>
</div>
);
}
// ─── Section header & skeleton ──────────────────────────────────────────────
function RailSectionHeader({
icon,
title,
count,
}: {
icon: ReactNode;
title: string;
count?: number;
}) {
return (
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{icon}
<span>{title}</span>
{count !== undefined && count > 0 && (
<span className="text-xs font-normal normal-case text-muted-foreground/70">({count})</span>
)}
</h2>
);
}
function RailSkeleton() {
return (
<div className="flex flex-col gap-5">
<Skeleton className="size-28 md:size-32 rounded-full -mt-16 md:-mt-20 border-4 border-background" />
<div className="space-y-2">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full mt-2" />
<Skeleton className="h-4 w-3/4" />
</div>
<Skeleton className="h-10 w-full rounded-full" />
<Skeleton className="h-32 w-full rounded-xl" />
</div>
);
}