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.
This commit is contained in:
Chad Curtis
2026-05-17 21:12:32 -05:00
parent 2a69747744
commit 2c8cd11153
9 changed files with 897 additions and 321 deletions
+7 -48
View File
@@ -17,45 +17,6 @@ const REPO_ROOT = path.resolve(__dirname, '..');
const INPUT = process.argv[2] ?? '/tmp/opencode/countries-110m.json';
const OUTPUT = path.join(REPO_ROOT, 'src/lib/landPolygons.ts');
/** Douglas-Peucker on a polyline of [lng, lat] pairs. */
function simplify(points, tolerance) {
if (points.length < 3) return points.slice();
const sqTol = tolerance * tolerance;
function sqSegDist(p, a, b) {
let x = a[0], y = a[1];
let dx = b[0] - x, dy = b[1] - y;
if (dx !== 0 || dy !== 0) {
const t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy);
if (t > 1) { x = b[0]; y = b[1]; }
else if (t > 0) { x += dx * t; y += dy * t; }
}
dx = p[0] - x;
dy = p[1] - y;
return dx * dx + dy * dy;
}
function dp(first, last, simplified) {
let maxSqDist = sqTol;
let index = -1;
for (let i = first + 1; i < last; i++) {
const sqDist = sqSegDist(points[i], points[first], points[last]);
if (sqDist > maxSqDist) { index = i; maxSqDist = sqDist; }
}
if (index !== -1) {
if (index - first > 1) dp(first, index, simplified);
simplified.push(points[index]);
if (last - index > 1) dp(index, last, simplified);
}
}
const last = points.length - 1;
const simplified = [points[0]];
dp(0, last, simplified);
simplified.push(points[last]);
return simplified;
}
const topo = JSON.parse(fs.readFileSync(INPUT, 'utf8'));
const layer = topo.objects.countries;
const transform = topo.transform;
@@ -114,19 +75,17 @@ for (const feature of layer.geometries) {
}
}
// Aggressive simplification: tolerance is in degrees. ~1.2° drops most coastal
// noise while keeping continent shapes recognizable at hero scale.
const TOLERANCE_DEG = 1.2;
// Drop tiny islands whose simplified ring would be near-useless.
const MIN_VERTS_AFTER = 4;
// Use the full Natural Earth 110m resolution. We skip Douglas-Peucker
// entirely so coastlines look organic at hero scale rather than blocky.
// We still quantize to 0.1° (well below the rendered pixel size on a
// ~600 px globe) which is a free ~30 % byte saving with no visible loss.
const MIN_VERTS = 3;
const simplifiedRings = [];
for (const ring of rings) {
const s = simplify(ring, TOLERANCE_DEG);
if (s.length < MIN_VERTS_AFTER) continue;
// Quantize to 0.1° to shave bytes — well below the resolution we render at.
if (ring.length < MIN_VERTS) continue;
const flat = [];
for (const [lng, lat] of s) {
for (const [lng, lat] of ring) {
flat.push(Math.round(lng * 10) / 10, Math.round(lat * 10) / 10);
}
simplifiedRings.push(flat);
+2 -14
View File
@@ -14,24 +14,12 @@ import {
type ParsedCampaign,
encodeCampaignNaddr,
} from '@/lib/campaign';
import { fetchBtcPrice, satsToUSDWhole } from '@/lib/bitcoin';
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';
/** Formats a sats count into `1,234,567 sats` or `0.012 BTC` once large. */
function formatSatsShort(sats: number): string {
if (sats >= 100_000_000) return `${(sats / 100_000_000).toFixed(2)} BTC`;
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(2)}M sats`;
if (sats >= 10_000) return `${(sats / 1_000).toFixed(0)}K sats`;
return `${sats.toLocaleString()} sats`;
}
function formatCampaignAmount(sats: number, btcPrice: number | undefined): string {
if (btcPrice) return satsToUSDWhole(sats, btcPrice);
return formatSatsShort(sats);
}
function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } {
const now = Math.floor(Date.now() / 1000);
const diff = unixSeconds - now;
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from 'react';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
interface CampaignHeroBackgroundProps {
/**
* Image URL for the active campaign. Each new URL crossfades over the
* previous one — we keep up to two layers mounted at a time so the
* transition is smooth even when the source changes mid-fade.
*/
imageUrl: string | undefined;
/** Optional className for the outer wrapper. */
className?: string;
}
interface Layer {
/** Stable key so React doesn't tear down the layer mid-transition. */
id: number;
/** Sanitized URL (or `null` for the gradient-only fallback). */
url: string | null;
}
const FADE_MS = 1500;
/**
* Full-bleed crossfading background built from the active campaign's banner
* image. Modelled after Treasures' HeroGallery: each image gets its own
* stacked layer and we toggle opacity to crossfade. The previous layer
* unmounts after the fade completes, so we never accumulate more than a
* couple of layers in the DOM.
*
* A warm tint + subtle film-grain SVG sit on top so headlines stay readable
* over any photo.
*/
export function CampaignHeroBackground({ imageUrl, className }: CampaignHeroBackgroundProps) {
const idRef = useRef(0);
const [layers, setLayers] = useState<Layer[]>([]);
const lastUrlRef = useRef<string | null>(null);
useEffect(() => {
const safe = sanitizeUrl(imageUrl) ?? null;
if (safe === lastUrlRef.current) return;
lastUrlRef.current = safe;
const id = ++idRef.current;
// Add the new layer; existing layers stay mounted so the crossfade has
// something to fade from.
setLayers((prev) => [...prev, { id, url: safe }]);
// After the fade completes, drop everything except the most recent
// layer to keep the DOM tidy.
const timeout = window.setTimeout(() => {
setLayers((prev) => prev.filter((l) => l.id === id));
}, FADE_MS + 50);
return () => window.clearTimeout(timeout);
}, [imageUrl]);
return (
<div className={cn('absolute inset-0 overflow-hidden', className)} aria-hidden="true">
{layers.map((layer, i) => {
const isTop = i === layers.length - 1;
return (
<div
key={layer.id}
className="absolute inset-0"
style={{
opacity: isTop ? 1 : 0,
transition: `opacity ${FADE_MS}ms ease-in-out`,
}}
>
{layer.url ? (
<img
src={layer.url}
alt=""
loading="eager"
decoding="async"
// Slow continuous pan toward the left — pairs with the
// right-anchored globe so the scene reads as moving toward
// the headline copy.
className="absolute inset-0 w-full h-full object-cover hero-pan-left"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-background to-secondary/40" />
)}
</div>
);
})}
{/* Warm tint + dark gradient — keeps foreground text legible without
completely washing the photo out. */}
<div className="absolute inset-0 bg-gradient-to-t from-background/85 via-background/55 to-background/40" />
<div className="absolute inset-0 bg-gradient-to-br from-primary/25 via-transparent to-secondary/30" />
{/* Film grain — same trick as Treasures' HeroGallery. Helps the
composited globe + photo feel like one image. */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ opacity: 0.18 }}
>
<filter id="hero-grain">
<feTurbulence type="fractalNoise" baseFrequency="0.7" numOctaves="2" stitchTiles="stitch" />
<feColorMatrix type="saturate" values="0" />
</filter>
<rect width="100%" height="100%" filter="url(#hero-grain)" />
</svg>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { Link } from 'react-router-dom';
import { ArrowRight, MapPin } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import { encodeCampaignNaddr, type ParsedCampaign } from '@/lib/campaign';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
interface HeroCampaignSpotlightProps {
/** Campaign to feature. `null` renders the empty placeholder. */
campaign: ParsedCampaign | null;
/** Show a skeleton while the parent is still loading featured campaigns. */
isLoading?: boolean;
/** Extra classes for the outer wrapper. */
className?: string;
}
/**
* Banner-overlay spotlight for the active campaign — title, summary,
* author, location, and a "View campaign" CTA — rendered directly on the
* hero photo (no card chrome). The hero photo IS the background, so this
* component is purely a text overlay.
*
* Parent (`CampaignsPage`) drives the `campaign` prop, cycling on a timer
* or pinning to whichever marker the user clicked on the globe.
*/
export function HeroCampaignSpotlight({
campaign,
isLoading = false,
className,
}: HeroCampaignSpotlightProps) {
// useAuthor must be called unconditionally to keep hook order stable —
// when there's no campaign yet we pass an empty pubkey and ignore the
// (no-op) result below. Same for donations + BTC price.
const author = useAuthor(campaign?.pubkey ?? '');
const { data: stats } = useCampaignDonations(campaign?.aTag);
const { data: btcPrice } = useBtcPrice();
if (isLoading && !campaign) {
return (
<div className={cn('space-y-1.5', className)}>
<Skeleton className="h-5 w-52 bg-white/20" />
<Skeleton className="h-3 w-64 bg-white/20" />
<Skeleton className="h-3 w-40 bg-white/20" />
</div>
);
}
if (!campaign) return null;
const naddr = encodeCampaignNaddr(campaign);
const meta = author.data?.metadata;
const authorName = meta?.display_name || meta?.name || genUserName(campaign.pubkey);
const authorPicture = sanitizeUrl(meta?.picture);
return (
<div
className={cn(
// Compact text block over the photo. Light text + subtle drop
// shadow for legibility, no card chrome — modeled after the
// Treasures hero overlay: tight, dense, low-key.
'space-y-1.5 text-foreground [text-shadow:0_1px_2px_rgb(0_0_0/0.4)]',
className,
)}
>
<p className="text-base font-semibold leading-snug line-clamp-1">
{campaign.title}
</p>
{campaign.summary && (
<p className="text-xs text-foreground/80 line-clamp-2 max-w-xs">
{campaign.summary}
</p>
)}
{/* Progress / goal. Hand-rolled instead of using <CampaignProgress>
so we can tune the bar for legibility on top of a photo: dark
translucent track, glowing primary fill. The percent label sits
inside the bar's empty area so the number reads even without a
goal. */}
{(() => {
const raised = stats?.totalSats ?? 0;
const goal = campaign.goalSats;
const pct = goal && goal > 0 ? Math.min(100, Math.round((raised / goal) * 100)) : 0;
return (
<div className="space-y-1.5 pt-1 max-w-xs">
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-black/40 ring-1 ring-white/15">
<div
className="absolute inset-y-0 left-0 rounded-full bg-primary shadow-[0_0_8px_hsl(var(--primary)/0.7)] motion-safe:transition-[width] motion-safe:duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex items-baseline justify-between gap-2 text-[11px] [text-shadow:none]">
<span className="font-semibold text-foreground">
{formatCampaignAmount(raised, btcPrice)}
</span>
{goal ? (
<span className="text-foreground/70">
of {formatCampaignAmount(goal, btcPrice)} goal
</span>
) : (
<span className="text-foreground/70">raised</span>
)}
</div>
</div>
);
})()}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-foreground/75 pt-0.5">
<span className="inline-flex items-center gap-1.5">
<Avatar className="size-4 ring-1 ring-white/40">
{authorPicture && <AvatarImage src={authorPicture} alt="" />}
<AvatarFallback className="text-[8px]">
{authorName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="font-medium">{authorName}</span>
</span>
{campaign.location && (
<span className="inline-flex items-center gap-1">
<MapPin className="size-3" />
<span className="truncate max-w-[16ch]">{campaign.location}</span>
</span>
)}
<Link
to={`/${naddr}`}
className="inline-flex items-center gap-1 font-medium text-primary hover:text-primary/80 focus-visible:outline-none focus-visible:underline"
>
View
<ArrowRight className="size-3" />
</Link>
</div>
</div>
);
}
+165 -33
View File
@@ -13,11 +13,20 @@ interface GeoPoint {
interface CampaignMarker extends GeoPoint {
/** Stable key for the marker (e.g. the campaign aTag). */
key: string;
/** Tooltip / accessible label shown on hover. */
label?: string;
}
interface HeroGlobeProps {
/** Markers to plot on top of the globe — one per geo-located campaign. */
markers?: CampaignMarker[];
/**
* Marker the user has selected. The selected marker gets a stronger glow
* and a slightly larger heart so it reads as the "live" one.
*/
selectedKey?: string | null;
/** Fires when the user clicks a marker. */
onMarkerClick?: (key: string) => void;
/** Optional className applied to the outer container. */
className?: string;
}
@@ -31,7 +40,7 @@ const LANDMASSES: readonly GeoPoint[][] = LAND_RINGS.map((flat) => {
return out;
});
const RADIUS = 240;
const RADIUS = 255;
const CENTER = 300;
/** Seconds per full revolution. Slow on purpose so the motion is ambient. */
const ROTATION_PERIOD_SECONDS = 90;
@@ -70,7 +79,12 @@ function project(lat: number, lng: number, rotationDeg: number) {
* refs so the component never re-renders during animation. Respects
* `prefers-reduced-motion` by holding at a static angle.
*/
export function HeroGlobe({ markers = [], className }: HeroGlobeProps) {
export function HeroGlobe({
markers = [],
selectedKey = null,
onMarkerClick,
className,
}: HeroGlobeProps) {
const landRef = useRef<SVGGElement | null>(null);
const markersRef = useRef<SVGGElement | null>(null);
@@ -94,6 +108,24 @@ export function HeroGlobe({ markers = [], className }: HeroGlobeProps) {
: (elapsedSeconds / ROTATION_PERIOD_SECONDS) * 360;
// --- Landmass polygons ---
//
// For each ring we walk vertex-by-vertex projecting through the
// orthographic camera. Vertices on the *front* of the sphere
// (z > 0) are kept as-is. Vertices on the *back* (z < 0) would
// otherwise project on top of front-side land — orthographic
// projection collapses depth — so we drop them.
//
// Where a ring crosses the visible limb (front ↔ back) we emit an
// interpolated point on the limb itself, so polygons that wrap
// around the side of the globe close cleanly along the sphere's
// outline instead of cutting across the disc interior.
//
// We also fade rings out over a narrow band near the limb so they
// don't pop on/off when crossing z = 0. Anything with maxZ below
// FADE_OUT is considered fully hidden; rings between FADE_OUT and
// FADE_IN ease in/out.
const FADE_OUT = 0.0;
const FADE_IN = 0.08;
const landEl = landRef.current;
if (landEl) {
const polygons = landEl.children;
@@ -101,24 +133,60 @@ export function HeroGlobe({ markers = [], className }: HeroGlobeProps) {
const ring = LANDMASSES[i];
const polygon = polygons[i] as SVGPolygonElement | undefined;
if (!polygon) continue;
// Project every vertex; if the whole ring is on the back hemisphere
// we just hide it. Rings that straddle the limb are clipped by the
// sphere mask defined in <defs>, so we can emit them unmodified.
// First pass: project every vertex, remembering z so we can
// detect front/back transitions cheaply.
const n = ring.length;
const xs = new Array<number>(n);
const ys = new Array<number>(n);
const zs = new Array<number>(n);
let maxZ = -1;
const parts: string[] = [];
for (let j = 0; j < ring.length; j++) {
for (let j = 0; j < n; j++) {
const p = project(ring[j].lat, ring[j].lng, rotation);
xs[j] = p.x;
ys[j] = p.y;
zs[j] = p.z;
if (p.z > maxZ) maxZ = p.z;
parts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
}
if (maxZ <= 0) {
if (maxZ <= FADE_OUT) {
polygon.setAttribute('opacity', '0');
continue;
}
// Second pass: emit only the visible portion. For each edge we
// include the endpoint when it's in front, and any limb-crossing
// we step over gets an interpolated point on the sphere edge.
const parts: string[] = [];
for (let j = 0; j < n; j++) {
const k = (j + 1) % n;
const zj = zs[j];
const zk = zs[k];
if (zj > 0) parts.push(`${xs[j].toFixed(1)},${ys[j].toFixed(1)}`);
if ((zj > 0) !== (zk > 0)) {
// Find the parameter t in [0,1] along this edge where z=0.
const t = zj / (zj - zk);
const ex = xs[j] + (xs[k] - xs[j]) * t;
const ey = ys[j] + (ys[k] - ys[j]) * t;
// Project the limb point onto the actual sphere edge so it
// never lands inside the disc.
const dx = ex - CENTER;
const dy = ey - CENTER;
const d = Math.hypot(dx, dy) || 1;
const lx = CENTER + (dx / d) * RADIUS;
const ly = CENTER + (dy / d) * RADIUS;
parts.push(`${lx.toFixed(1)},${ly.toFixed(1)}`);
}
}
if (parts.length < 3) {
polygon.setAttribute('opacity', '0');
continue;
}
polygon.setAttribute('points', parts.join(' '));
// Slightly fade rings sitting close to the limb so the sphere
// edges feel less hard.
polygon.setAttribute('opacity', Math.min(1, 0.5 + maxZ * 0.6).toFixed(2));
// Smooth fade as rings come around the limb. `fade` clamps to
// [0,1] over the narrow FADE_OUT→FADE_IN band, then we keep
// adding the small depth-based dimming used before.
const fade = Math.min(1, Math.max(0, (maxZ - FADE_OUT) / (FADE_IN - FADE_OUT)));
polygon.setAttribute('opacity', (fade * Math.min(1, 0.55 + maxZ * 0.55)).toFixed(2));
}
}
@@ -133,9 +201,16 @@ export function HeroGlobe({ markers = [], className }: HeroGlobeProps) {
const p = project(m.lat, m.lng, rotation);
if (p.z <= 0) {
group.setAttribute('opacity', '0');
// Pull off-canvas so backside markers don't intercept clicks.
group.setAttribute('transform', 'translate(-1000 -1000)');
continue;
}
group.setAttribute('transform', `translate(${p.x.toFixed(2)} ${p.y.toFixed(2)})`);
// Selected marker scales up subtly to read as "you are here".
const scale = m.key === selectedKey ? 1.35 : 1;
group.setAttribute(
'transform',
`translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) scale(${scale})`,
);
group.setAttribute('opacity', (0.55 + p.z * 0.45).toFixed(2));
}
}
@@ -147,24 +222,26 @@ export function HeroGlobe({ markers = [], className }: HeroGlobeProps) {
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [markers, ringSizes]);
}, [markers, ringSizes, selectedKey]);
return (
<div className={className} aria-hidden="true">
<div className={className}>
<svg
viewBox="0 0 600 600"
className="size-full"
role="presentation"
role="img"
aria-label="Globe showing locations of active fundraising campaigns"
focusable="false"
>
<defs>
{/* Sphere base: warm cream lit from the upper-left, fading to a
slightly cooler shadow on the lower-right. Deliberately
non-blue to avoid the satellite/HUD look. */}
non-blue to avoid the satellite/HUD look. Kept partially
translucent so the hero photo bleeds through softly. */}
<radialGradient id="hero-globe-base" cx="35%" cy="32%" r="75%">
<stop offset="0%" stopColor="hsl(40 90% 96%)" />
<stop offset="55%" stopColor="hsl(34 60% 86%)" />
<stop offset="100%" stopColor="hsl(28 35% 70%)" />
<stop offset="0%" stopColor="hsl(40 90% 96% / 0.75)" />
<stop offset="55%" stopColor="hsl(34 60% 86% / 0.7)" />
<stop offset="100%" stopColor="hsl(28 35% 70% / 0.65)" />
</radialGradient>
{/* Subtle warm rim light along the limb. */}
<radialGradient id="hero-globe-rim" cx="50%" cy="50%" r="50%">
@@ -176,10 +253,17 @@ export function HeroGlobe({ markers = [], className }: HeroGlobeProps) {
<stop offset="0%" stopColor="hsl(50 100% 98% / 0.7)" />
<stop offset="100%" stopColor="hsl(50 100% 98% / 0)" />
</radialGradient>
{/* Marker glow halo. */}
{/* Marker glow halo. Soft, warm, no pulsing. */}
<radialGradient id="hero-marker-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.7" />
<stop offset="70%" stopColor="hsl(var(--primary))" stopOpacity="0.15" />
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.55" />
<stop offset="70%" stopColor="hsl(var(--primary))" stopOpacity="0.12" />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
</radialGradient>
{/* Stronger halo used for the selected marker so it visibly leads
the eye to whatever the spotlight card is currently showing. */}
<radialGradient id="hero-marker-glow-active" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
<stop offset="55%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
</radialGradient>
{/* Clip everything to the sphere so polygons straddling the
@@ -197,8 +281,8 @@ export function HeroGlobe({ markers = [], className }: HeroGlobeProps) {
<g
ref={landRef}
fill="hsl(30 55% 52%)"
stroke="hsl(28 50% 40% / 0.35)"
strokeWidth="0.6"
stroke="hsl(28 50% 40% / 0.25)"
strokeWidth="0.3"
strokeLinejoin="round"
>
{LANDMASSES.map((_, i) => (
@@ -224,16 +308,64 @@ export function HeroGlobe({ markers = [], className }: HeroGlobeProps) {
pointerEvents="none"
/>
{/* Campaign markers — a soft halo and a small solid dot. No pulsing,
no targeting reticle, no "ping" animation. */}
{/* Campaign markers — a small heart glyph with a warm glow halo.
Each marker is a button: clicking selects the campaign, which
the parent uses to populate the spotlight card. */}
<g ref={markersRef}>
{markers.map((m) => (
<g key={m.key} opacity={0} transform="translate(-10 -10)">
<circle r={11} fill="url(#hero-marker-glow)" />
<circle r={3} fill="hsl(var(--primary))" />
<circle r={1.2} fill="hsl(40 100% 96%)" />
{markers.map((m) => {
const isSelected = m.key === selectedKey;
return (
<g
key={m.key}
opacity={0}
transform="translate(-1000 -1000)"
role={onMarkerClick ? 'button' : undefined}
tabIndex={onMarkerClick ? 0 : undefined}
aria-label={m.label ?? 'View campaign'}
aria-pressed={onMarkerClick ? isSelected : undefined}
onClick={onMarkerClick ? () => onMarkerClick(m.key) : undefined}
onKeyDown={
onMarkerClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onMarkerClick(m.key);
}
}
: undefined
}
style={{
cursor: onMarkerClick ? 'pointer' : undefined,
outline: 'none',
}}
>
{/* Glow halo (stronger for the active marker). */}
<circle
r={isSelected ? 16 : 12}
fill={`url(#hero-marker-glow${isSelected ? '-active' : ''})`}
/>
{/* Heart glyph. Path is centered at the origin (~14×12 units)
so the parent <g>'s translate+scale lands it on the globe. */}
<path
d="M0,3.5 C-3.5,1 -7,-1.5 -7,-4.5 C-7,-7 -5,-8.5 -3,-8.5 C-1.5,-8.5 -0.5,-7.5 0,-6.5 C0.5,-7.5 1.5,-8.5 3,-8.5 C5,-8.5 7,-7 7,-4.5 C7,-1.5 3.5,1 0,3.5 Z"
fill="hsl(var(--primary))"
stroke="hsl(40 100% 98%)"
strokeWidth="0.6"
strokeLinejoin="round"
/>
{/* Tiny inner highlight to make the heart pop on the warm
landmass without needing a heavy outline. */}
<ellipse cx={-2.5} cy={-5.5} rx={1.5} ry={1} fill="hsl(40 100% 98% / 0.55)" />
{/* Transparent hit target — much easier to click/tap than the
tiny visible heart, especially on touch. */}
<circle
r={14}
fill="transparent"
style={{ cursor: onMarkerClick ? 'pointer' : 'default' }}
/>
</g>
))}
);
})}
</g>
</svg>
</div>
+19
View File
@@ -722,3 +722,22 @@
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
/* Slow Ken-Burns-style pan for full-bleed hero images. The image is
oversized (scale 1.12) so the translation never reveals the edge. */
@keyframes heroPanRight {
0% { transform: scale(1.12) translateX(-1.5%); }
50% { transform: scale(1.12) translateX(1.5%); }
100% { transform: scale(1.12) translateX(-1.5%); }
}
@keyframes heroPanLeft {
0% { transform: scale(1.12) translateX(1.5%); }
50% { transform: scale(1.12) translateX(-1.5%); }
100% { transform: scale(1.12) translateX(1.5%); }
}
.hero-pan-right { animation: heroPanRight 28s ease-in-out infinite; }
.hero-pan-left { animation: heroPanLeft 28s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.hero-pan-right, .hero-pan-left { animation: none; }
}
+27
View File
@@ -0,0 +1,27 @@
import { satsToUSDWhole } from '@/lib/bitcoin';
/**
* Formats a sats amount into a short, human-readable string.
*
* - `< 10,000` sats — shows the exact number with thousands separators.
* - `10,000 999,999` sats — rounds to the nearest thousand (`12K sats`).
* - `1,000,000 99,999,999` sats — two decimals of millions (`1.23M sats`).
* - `>= 100,000,000` sats (1 BTC) — switches to BTC with two decimals.
*/
export function formatSatsShort(sats: number): string {
if (sats >= 100_000_000) return `${(sats / 100_000_000).toFixed(2)} BTC`;
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(2)}M sats`;
if (sats >= 10_000) return `${(sats / 1_000).toFixed(0)}K sats`;
return `${sats.toLocaleString()} sats`;
}
/**
* Renders a sats count as USD (whole dollars) when a BTC price is
* available, falling back to {@link formatSatsShort} otherwise. Used by
* campaign cards and the hero spotlight so totals are consistent across
* the app.
*/
export function formatCampaignAmount(sats: number, btcPrice: number | undefined): string {
if (btcPrice) return satsToUSDWhole(sats, btcPrice);
return formatSatsShort(sats);
}
+286 -188
View File
File diff suppressed because one or more lines are too long
+138 -34
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useQueries } from '@tanstack/react-query';
@@ -10,6 +10,8 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { HeroGlobe } from '@/components/HeroGlobe';
import { HeroCampaignSpotlight } from '@/components/HeroCampaignSpotlight';
import { CampaignHeroBackground } from '@/components/CampaignHeroBackground';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLayoutOptions } from '@/contexts/LayoutContext';
@@ -117,70 +119,172 @@ export function CampaignsPage() {
[allCampaigns, featuredCoords],
);
// Geo-locate campaigns for the hero globe. We match the free-form
// `location` field against ISO 3166-1 names/codes and fall back to the
// country capital coordinates. Campaigns without a resolvable location
// simply don't get a marker.
const globeMarkers = useMemo(() => {
const out: { key: string; lat: number; lng: number }[] = [];
const seen = new Set<string>();
for (const c of allCampaigns ?? []) {
if (!c.location) continue;
// Build the spotlight pool: every campaign that has both a parseable
// location AND would make sense to feature. Featured campaigns come first
// (in their hand-picked order), then everything else, newest first.
//
// Each entry resolves a country code from the free-form `location` field
// and pulls the country's capital coordinates from `getCoordinates`. The
// globe uses these to place a heart marker; the spotlight card uses the
// full `campaign` object.
const spotlightables = useMemo(() => {
type Entry = {
key: string;
campaign: ParsedCampaign;
lat: number;
lng: number;
};
const out: Entry[] = [];
const seenAtag = new Set<string>();
const seenCountry = new Set<string>();
const add = (c: ParsedCampaign) => {
if (seenAtag.has(c.aTag)) return;
if (!c.location) return;
const match = searchCountry(c.location);
if (!match) continue;
if (!match) return;
const coords = getCoordinates(match.country.code);
if (!coords) continue;
if (!coords) return;
// Deduplicate by country so a single popular country doesn't pile
// dozens of overlapping markers on top of each other.
if (seen.has(match.country.code)) continue;
seen.add(match.country.code);
out.push({ key: c.aTag, lat: coords.latitude, lng: coords.longitude });
// dozens of overlapping markers on top of each other. We keep the
// first one we see, which — given the iteration order below — means
// featured wins, then newest.
if (seenCountry.has(match.country.code)) return;
seenAtag.add(c.aTag);
seenCountry.add(match.country.code);
out.push({ key: c.aTag, campaign: c, lat: coords.latitude, lng: coords.longitude });
};
for (const c of visibleFeatured) {
if (c) add(c);
}
for (const c of allCampaigns ?? []) add(c);
return out;
}, [allCampaigns]);
}, [visibleFeatured, allCampaigns]);
const globeMarkers = useMemo(
() =>
spotlightables.map((s) => ({
key: s.key,
lat: s.lat,
lng: s.lng,
label: s.campaign.title,
})),
[spotlightables],
);
// Selection lives here so the globe and the spotlight card stay in sync.
// `null` means "auto-cycle through the spotlightables"; clicking a marker
// pins the selection until the user clicks a different one.
const [selectedKey, setSelectedKey] = useState<string | null>(null);
// A separate cursor advances when no marker is selected, so cycling
// continues to drive the spotlight even while the user is reading.
const [cycleIndex, setCycleIndex] = useState(0);
useEffect(() => {
if (selectedKey !== null) return;
if (spotlightables.length <= 1) return;
const id = window.setInterval(() => {
setCycleIndex((i) => (i + 1) % spotlightables.length);
}, 6_000);
return () => window.clearInterval(id);
}, [selectedKey, spotlightables.length]);
// Resolve the spotlight to actually display.
const spotlightCampaign = useMemo(() => {
if (selectedKey) {
return spotlightables.find((s) => s.key === selectedKey)?.campaign ?? null;
}
if (spotlightables.length === 0) return null;
return spotlightables[cycleIndex % spotlightables.length].campaign;
}, [selectedKey, cycleIndex, spotlightables]);
// The key the globe should highlight matches whatever the card shows.
const highlightedMarkerKey = useMemo(() => {
if (selectedKey) return selectedKey;
if (spotlightables.length === 0) return null;
return spotlightables[cycleIndex % spotlightables.length].key;
}, [selectedKey, cycleIndex, spotlightables]);
return (
<main className="min-h-screen pb-16">
{/* Hero */}
<section className="relative overflow-hidden border-b border-border bg-gradient-to-br from-primary/15 via-background to-secondary/40">
{/* Slow-spinning globe sits behind the text. Positioned to the right
on wider viewports so it doesn't fight the headline for attention,
and pulled mostly off-screen on mobile so it still reads as an
ambient accent without crowding the copy. */}
<div
className="pointer-events-none absolute inset-y-0 right-[-30%] sm:right-[-20%] lg:right-[-8%] flex items-center justify-end opacity-60 sm:opacity-70"
aria-hidden="true"
>
{/* Hero.
Layered, back-to-front:
1. CampaignHeroBackground — full-bleed crossfading banner image
from the currently-spotlit campaign, with a warm tint + film
grain so headlines stay legible.
2. HeroGlobe — large slow-spinning globe anchored to the right,
heart markers click-select campaigns.
3. Headline column — title + paragraph + CTAs, top-left.
4. HeroCampaignSpotlight — title + summary + "View campaign"
button for the active campaign, bottom-right.
Inspired by the Treasures HeroGallery pattern: the photo IS the
background, everything else floats over it. */}
<section className="relative overflow-hidden border-b border-border bg-secondary/40">
<CampaignHeroBackground imageUrl={spotlightCampaign?.image} />
{/* Globe sits in front of the photo BG but behind the text /
spotlight card. Anchored to the right edge and pushed further
off-screen so most of it sits beyond the viewport — the visible
arc reads as a horizon rather than a centered illustration. */}
<div className="absolute inset-0 flex items-center justify-end pointer-events-none">
<div className="pointer-events-auto translate-x-[40%] sm:translate-x-[32%] lg:translate-x-[22%] opacity-90">
<HeroGlobe
markers={globeMarkers}
className="aspect-square w-[520px] sm:w-[600px] lg:w-[680px] max-w-none"
selectedKey={highlightedMarkerKey}
onMarkerClick={(key) =>
// Toggle off when the user re-clicks the active marker,
// restoring the auto-cycle.
setSelectedKey((prev) => (prev === key ? null : key))
}
className="aspect-square w-[560px] sm:w-[680px] lg:w-[820px] max-w-none drop-shadow-2xl"
/>
</div>
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-14 lg:py-20">
<div className="max-w-3xl space-y-5">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.1]">
{/* Foreground content — headline + CTAs at the top, spotlight info
at the bottom. Both share the same container so they line up
against the same left edge as the rest of the page. */}
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-14 lg:py-20 min-h-[560px] sm:min-h-[600px] lg:min-h-[640px] flex flex-col">
<div className="space-y-5 max-w-2xl">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.1] drop-shadow-sm">
Connecting activists to unstoppable funding.
</h1>
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl">
<p className="text-base sm:text-lg text-foreground/80 max-w-2xl">
Raise Bitcoin directly from supporters around the world. Every donation settles
straight to your campaign's beneficiaries, with no middlemen, no chargebacks, and no
platform holding your funds.
</p>
<div className="flex flex-wrap gap-3 pt-2">
<Button size="lg" asChild className="rounded-full">
<Button size="lg" asChild className="rounded-full shadow-lg">
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-2" />
Start a campaign
</Link>
</Button>
{!user && (
<Button variant="outline" size="lg" asChild className="rounded-full">
<Button
variant="outline"
size="lg"
asChild
className="rounded-full bg-background/70 backdrop-blur"
>
<a href="#campaigns">Explore campaigns</a>
</Button>
)}
</div>
</div>
{(spotlightCampaign || (featuredLoading && spotlightables.length === 0)) && (
<div className="mt-auto pt-10 max-w-sm">
<HeroCampaignSpotlight
campaign={spotlightCampaign}
isLoading={featuredLoading && spotlightables.length === 0}
/>
</div>
)}
</div>
</section>