Files
eranos/src/components/CampaignCard.tsx
T
Chad Curtis 2c8cd11153 Rebuild campaigns hero around photo BG + globe + spotlight
The hero is now layered like Treasures' HeroGallery:

- CampaignHeroBackground (new) — full-bleed banner image from the
  currently-spotlit campaign, crossfading over ~1.5 s and panning left.
  Warm tint + film grain overlay so foreground text stays legible.
- HeroGlobe — pushed to the right edge with a larger radius, slightly
  translucent so the photo bleeds through. Hearts replace the old dots
  for marker symbols; clicking one selects that campaign.
- HeroCampaignSpotlight (new) — minimal text overlay anchored to the
  bottom-left of the hero container (title, summary, avatar + author,
  location, progress bar with goal, 'View' link). No card chrome.

Land polygons are now the full Natural Earth 110m fidelity (~10.5k
vertices) instead of being heavily Douglas-Peucker'd, so coastlines
look organic rather than chunky. Back-hemisphere rings are now
properly hidden by walking each edge and either dropping back-side
vertices outright or interpolating to the sphere limb where a ring
crosses it — fixes the 'phantom continents through the front' bug.
Rings additionally fade in/out over a narrow z-band near the limb
instead of popping at z = 0.

Markers also have proper z-fade and pull off-canvas when on the back
so they can't intercept clicks they aren't visible for. Selected
markers scale 1.35x with a stronger glow so the user can tell which
campaign the spotlight refers to.

Other cleanup:

- formatCampaignAmount + formatSatsShort move out of CampaignCard.tsx
  into src/lib/formatCampaignAmount.ts so CampaignCard stops failing
  the react-refresh/only-export-components lint.
- Hero CTAs drop the 'Unstoppable fundraising on Nostr' pill and the
  em dash from the supporting copy.
- New keyframes (heroPanLeft / heroPanRight) for the slow Ken-Burns
  pan on the background photos, with prefers-reduced-motion respected.
2026-05-17 21:15:05 -05:00

250 lines
8.6 KiB
TypeScript

import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { CalendarClock, HandHeart, MapPin, Target, Users, Archive } from 'lucide-react';
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 { useAuthor } from '@/hooks/useAuthor';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import {
CAMPAIGN_CATEGORY_LABELS,
type ParsedCampaign,
encodeCampaignNaddr,
} from '@/lib/campaign';
import { fetchBtcPrice } from '@/lib/bitcoin';
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } {
const now = Math.floor(Date.now() / 1000);
const diff = unixSeconds - now;
if (diff <= 0) return { label: 'Ended', isPast: true };
const days = Math.ceil(diff / 86_400);
if (days <= 1) return { label: 'Ends today', isPast: false };
if (days < 30) return { label: `${days} days left`, isPast: false };
const months = Math.round(days / 30);
return { label: `${months} mo left`, isPast: false };
}
/** Short helper rendered both inline (cards) and in the detail page. */
export function CampaignProgress({
raisedSats,
goalSats,
btcPrice,
className,
}: {
raisedSats: number;
goalSats?: number;
btcPrice?: number;
className?: string;
}) {
const pct = goalSats && goalSats > 0 ? Math.min(100, Math.round((raisedSats / goalSats) * 100)) : 0;
return (
<div className={cn('space-y-1.5', className)}>
<Progress value={pct} className="h-2" />
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="font-semibold">{formatCampaignAmount(raisedSats, btcPrice)}</span>
{goalSats ? (
<span className="text-muted-foreground">of {formatCampaignAmount(goalSats, btcPrice)} goal</span>
) : (
<span className="text-muted-foreground">raised</span>
)}
</div>
</div>
);
}
interface CampaignCardProps {
campaign: ParsedCampaign;
/** Visual variant: `compact` for grid items, `featured` for hero placement. */
variant?: 'compact' | 'featured';
className?: string;
}
/**
* Renders a single campaign as a clickable card. The whole card is a
* `<Link>` to the campaign's naddr-based detail route.
*/
export function CampaignCard({ campaign, variant = 'compact', className }: CampaignCardProps) {
const author = useAuthor(campaign.pubkey);
const { data: stats } = useCampaignDonations(campaign.aTag);
const { data: btcPrice } = useQuery({
queryKey: ['btc-price'],
queryFn: fetchBtcPrice,
staleTime: 30_000,
refetchInterval: 60_000,
});
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
const cover = sanitizeUrl(campaign.image);
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 isFeatured = variant === 'featured';
return (
<Link
to={`/${naddr}`}
className={cn(
'group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5',
className,
)}
>
<Card
className={cn(
'overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col',
isFeatured && 'sm:flex-row sm:items-stretch',
)}
>
{/* Cover image */}
<div
className={cn(
'relative w-full bg-gradient-to-br from-primary/15 via-primary/5 to-secondary',
isFeatured ? 'aspect-[16/10] sm:aspect-auto sm:w-1/2 sm:min-h-[280px]' : 'aspect-[16/9]',
)}
>
{cover ? (
<img
src={cover}
alt=""
loading="lazy"
className="absolute inset-0 size-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<HandHeart className="size-12 text-primary/40" />
</div>
)}
{campaign.category && (
<Badge
variant="secondary"
className="absolute top-3 left-3 backdrop-blur bg-background/80 border-border/40"
>
{CAMPAIGN_CATEGORY_LABELS[campaign.category]}
</Badge>
)}
{campaign.archived && (
<Badge
variant="secondary"
className="absolute top-3 right-3 backdrop-blur bg-background/85 border-border/40"
>
<Archive className="size-3.5 mr-1" />
Archived
</Badge>
)}
</div>
{/* Body */}
<div className={cn('flex flex-col gap-3 p-5', isFeatured && 'sm:w-1/2 sm:p-6')}>
<div className="space-y-2">
<h3
className={cn(
'font-bold leading-tight tracking-tight',
isFeatured ? 'text-2xl sm:text-3xl' : 'text-lg',
)}
>
{campaign.title}
</h3>
{campaign.summary && (
<p
className={cn(
'text-muted-foreground',
isFeatured ? 'text-base line-clamp-3' : 'text-sm line-clamp-2',
)}
>
{campaign.summary}
</p>
)}
</div>
<div className="flex-1" />
<CampaignProgress raisedSats={raisedSats} goalSats={campaign.goalSats} btcPrice={btcPrice} />
{/* Meta row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
<span className="inline-flex items-center gap-1.5">
<Users className="size-3.5" />
{campaign.recipients.length}{' '}
{campaign.recipients.length === 1 ? 'recipient' : 'recipients'}
</span>
{stats && stats.donorCount > 0 && (
<span className="inline-flex items-center gap-1.5">
<Target className="size-3.5" />
{stats.donorCount} {stats.donorCount === 1 ? 'donor' : 'donors'}
</span>
)}
{campaign.location && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" />
{campaign.location}
</span>
)}
{deadline && (
<span
className={cn(
'inline-flex items-center gap-1.5',
deadline.isPast && 'text-destructive',
)}
>
<CalendarClock className="size-3.5" />
{deadline.label}
</span>
)}
</div>
<div className="text-xs text-muted-foreground border-t border-border/60 pt-3 truncate">
by <span className="font-medium text-foreground">{creatorName}</span>
</div>
</div>
</Card>
</Link>
);
}
/** Loading placeholder mirroring {@link CampaignCard} dimensions. */
export function CampaignCardSkeleton({
variant = 'compact',
className,
}: {
variant?: 'compact' | 'featured';
className?: string;
}) {
const isFeatured = variant === 'featured';
return (
<Card
className={cn(
'overflow-hidden border-border/70 shadow-sm h-full flex flex-col',
isFeatured && 'sm:flex-row sm:items-stretch',
className,
)}
>
<Skeleton
className={cn(
'w-full rounded-none',
isFeatured ? 'aspect-[16/10] sm:aspect-auto sm:w-1/2 sm:min-h-[280px]' : 'aspect-[16/9]',
)}
/>
<div className={cn('flex-1 p-5 space-y-3', isFeatured && 'sm:w-1/2 sm:p-6')}>
<Skeleton className={cn('w-3/4', isFeatured ? 'h-7' : 'h-5')} />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<div className="flex-1" />
<Skeleton className="h-2 w-full" />
<Skeleton className="h-3 w-32" />
</div>
</Card>
);
}