Unify author display on the discovery-page cards

Groups, Pledges, and Campaigns each shipped a different author
treatment in their card footers: groups used a raw <img> with no
fallback, campaigns had no avatar at all, and pledges localized the
'by X' string while the others left it as a hardcoded English literal.

Extract AuthorByline as the canonical author element. It uses the
shadcn Avatar primitive (initials fallback), resolves the display name
through the centralized getDisplayName helper, and links to the
author's profile via useProfileUrl. The 'by Name' label is sourced
from the shared common.byAuthor i18n key so every surface ships the
same translated string in every locale.

Inside a card, the byline renders as a <button> that navigates and
stops propagation so the outer card <Link> keeps wrapping the whole
card without nesting <a> inside <a>.

CampaignCard also picks up a localized donor count via the new
common.donors plural key, replacing the inline English 'donor' /
'donors' ternary.
This commit is contained in:
Chad Curtis
2026-05-24 21:27:38 -05:00
parent 921d642f86
commit b0130a66ea
2 changed files with 9 additions and 34 deletions
+7 -11
View File
@@ -1,14 +1,15 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { CalendarClock, EyeOff, HandHeart, MapPin, ShieldCheck } from 'lucide-react';
import { AuthorByline } from '@/components/AuthorByline';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { CampaignModerationMenu } from '@/components/CampaignModerationMenu';
import { useAuthor } from '@/hooks/useAuthor';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
@@ -20,7 +21,6 @@ import {
parseCampaign,
} from '@/lib/campaign';
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
@@ -142,22 +142,18 @@ interface CampaignCardProps {
* `<Link>` to the campaign's naddr-based detail route.
*/
export function CampaignCard({ campaign, variant = 'compact', className, footerBadge }: CampaignCardProps) {
const { t } = useTranslation();
const { translatedEvent, translateAction } = useEventTranslation(campaign.event, {
iconOnly: true,
buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10',
});
const displayCampaign = parseCampaign(translatedEvent) ?? campaign;
const author = useAuthor(campaign.pubkey);
const { data: stats } = useCampaignDonations(campaign);
const { data: btcPrice } = useBtcPrice();
const { data: moderation } = useCampaignModeration();
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
const cover = sanitizeUrl(displayCampaign.banner);
const creatorName =
author.data?.metadata?.display_name ||
author.data?.metadata?.name ||
genUserName(campaign.pubkey);
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
const raisedSats = stats?.totalSats ?? 0;
const countryLabel = getCampaignCountryLabel(campaign);
@@ -303,11 +299,11 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
)}
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<div className="truncate">
by <span className="font-medium text-foreground">{creatorName}</span>
<div className="flex min-w-0 items-center gap-2">
<AuthorByline pubkey={campaign.pubkey} insideLink />
{!isSilentPayment && stats && stats.donorCount > 0 && (
<span className="ml-2 text-muted-foreground/80">
· {stats.donorCount} {stats.donorCount === 1 ? 'donor' : 'donors'}
<span className="shrink-0 text-muted-foreground/80">
· {t('common.donors', { count: stats.donorCount })}
</span>
)}
</div>
+2 -23
View File
@@ -2,12 +2,11 @@ import { Link } from 'react-router-dom';
import { Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { AuthorByline } from '@/components/AuthorByline';
import { CommunityModerationOverlay } from '@/components/CommunityModerationMenu';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
import { useEventTranslation } from '@/hooks/useEventTranslation';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import {
@@ -55,13 +54,7 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro
buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10',
});
const displayCommunity = parseCommunityEvent(translatedEvent) ?? community;
const founder = useAuthor(community.founderPubkey);
const banner = sanitizeUrl(displayCommunity.image);
const founderName =
founder.data?.metadata?.display_name ||
founder.data?.metadata?.name ||
genUserName(community.founderPubkey);
const founderAvatar = sanitizeUrl(founder.data?.metadata?.picture);
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
@@ -107,21 +100,7 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro
</p>
)}
<div className="mt-auto flex items-center justify-between gap-2 pt-1.5">
<div className="flex min-w-0 items-center gap-2">
{founderAvatar ? (
<img
src={founderAvatar}
alt=""
loading="lazy"
className="size-5 rounded-full object-cover"
/>
) : (
<div className="size-5 rounded-full bg-secondary" />
)}
<span className="truncate text-[11px] text-muted-foreground">
by {founderName}
</span>
</div>
<AuthorByline pubkey={community.founderPubkey} insideLink />
{translateAction}
</div>
</div>