Merge branch 'main' of gitlab.com:soapbox-pub/agora
This commit is contained in:
Generated
+10
@@ -34,6 +34,7 @@
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||
"@fontsource/bebas-neue": "^5.2.7",
|
||||
"@fontsource/bungee-shade": "^5.2.7",
|
||||
"@fontsource/caveat": "^5.2.8",
|
||||
"@fontsource/cherry-bomb-one": "^5.2.7",
|
||||
@@ -1456,6 +1457,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/bebas-neue": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/bebas-neue/-/bebas-neue-5.2.7.tgz",
|
||||
"integrity": "sha512-DsmBrmq55d9BCU0mt4DT4RZDdH8vhWRKEUOfbuNB1EEjMuwbtFvM8N+3gIlkYSFbsb10P8Q19BV5OdpMu2h0fA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/bungee-shade": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/bungee-shade/-/bungee-shade-5.2.7.tgz",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||
"@fontsource/bebas-neue": "^5.2.7",
|
||||
"@fontsource/bungee-shade": "^5.2.7",
|
||||
"@fontsource/caveat": "^5.2.8",
|
||||
"@fontsource/cherry-bomb-one": "^5.2.7",
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Dark vertical scrim — strong at the bottom (spotlight card) and
|
||||
lighter at the top so the photo still reads. Uses black instead of
|
||||
background so the overlay is consistent across light/dark themes. */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/45 to-black/15" />
|
||||
{/* Warm primary tint — gives the hero its brand feel. */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-primary/10" />
|
||||
|
||||
{/* Left wash — mobile only, where the globe arc crosses the headline.
|
||||
Dark so white headline text has a reliable backdrop. */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/75 via-black/35 to-transparent sm:hidden" />
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -202,7 +202,7 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
|
||||
// Combine existing metadata with new values
|
||||
const data: Record<string, unknown> = { ...metadata, ...standardMetadata };
|
||||
|
||||
// Strip any legacy avatar shape data from old Ditto-style profiles
|
||||
// Strip any legacy avatar-shape field carried over from older clients.
|
||||
delete data.shape;
|
||||
|
||||
// Clean up empty values in standard metadata
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, MapPin, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { encodeCampaignNaddr, getCampaignCountryLabel, 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, formatUsdGoal, satsToUsd } 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 ?? undefined);
|
||||
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);
|
||||
const countryLabel = getCampaignCountryLabel(campaign);
|
||||
const isSilentPayment = campaign.wallet.mode === 'sp';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Compact text block over the photo — always white regardless of
|
||||
// theme since the hero is always a dark-scrimed photo.
|
||||
'space-y-1.5 text-white hero-text-shadow-soft',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="text-base font-semibold leading-snug line-clamp-1">
|
||||
{campaign.title}
|
||||
</p>
|
||||
|
||||
{campaign.summary && (
|
||||
<p className="text-xs text-white/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. When the campaign has no
|
||||
goal tag, the bar is omitted entirely and we only show the raised
|
||||
total. Silent-payment campaigns hide totals by design (per
|
||||
NIP.md Kind 33863). */}
|
||||
{isSilentPayment ? (
|
||||
<div className="space-y-1.5 pt-1 max-w-xs">
|
||||
<div className="inline-flex items-center gap-1.5 text-[11px] text-white/85 [text-shadow:none]">
|
||||
<ShieldCheck className="size-3" />
|
||||
<span>Private campaign — totals not public</span>
|
||||
</div>
|
||||
{campaign.goalUsd && campaign.goalUsd > 0 && (
|
||||
<div className="text-[11px] text-white/70 [text-shadow:none]">
|
||||
Target: {formatUsdGoal(campaign.goalUsd)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (() => {
|
||||
const raised = stats?.totalSats ?? 0;
|
||||
const goal = campaign.goalUsd;
|
||||
const hasGoal = !!goal && goal > 0;
|
||||
const raisedUsd = satsToUsd(raised, btcPrice);
|
||||
const pct = hasGoal && raisedUsd !== undefined
|
||||
? Math.min(100, Math.round((raisedUsd / goal!) * 100))
|
||||
: 0;
|
||||
return (
|
||||
<div className="space-y-1.5 pt-1 max-w-xs">
|
||||
{hasGoal && (
|
||||
<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-white">
|
||||
{formatCampaignAmount(raised, btcPrice)}
|
||||
{!hasGoal && <span className="ml-1 font-normal text-white/70">raised</span>}
|
||||
</span>
|
||||
{hasGoal && (
|
||||
<span className="text-white/70">
|
||||
of {formatUsdGoal(goal!)} goal
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-white/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>
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="size-3" />
|
||||
<span className="truncate max-w-[16ch]">{countryLabel}</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>
|
||||
);
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { LAND_RINGS } from '@/lib/landPolygons';
|
||||
import { HOPE_PALETTE, type HopeHue } from '@/lib/hopePalette';
|
||||
|
||||
/** Geographic point used by the globe projection. */
|
||||
interface GeoPoint {
|
||||
/** Latitude in degrees, [-90, 90]. */
|
||||
lat: number;
|
||||
/** Longitude in degrees, [-180, 180]. */
|
||||
lng: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual variant for a globe marker. Each kind gets its own glyph + halo
|
||||
* so the three "threads" of Discover — campaigns, communities, and
|
||||
* country activity — read distinctly without needing legend chrome.
|
||||
*/
|
||||
export type GlobeMarkerKind = 'campaign' | 'community' | 'country-pulse';
|
||||
|
||||
interface CampaignMarker extends GeoPoint {
|
||||
/** Stable key for the marker (e.g. the campaign aTag). */
|
||||
key: string;
|
||||
/** Tooltip / accessible label shown on hover. */
|
||||
label?: string;
|
||||
/**
|
||||
* Visual style of this marker. Defaults to `'campaign'` so existing
|
||||
* callers (the campaigns hero) keep their heart markers unchanged.
|
||||
*/
|
||||
kind?: GlobeMarkerKind;
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* Active hopeful hue. Drives the outer halo color and the back-lit
|
||||
* limb tint so the globe agrees with the surrounding {@link HeroAtmosphere}.
|
||||
*/
|
||||
hue?: HopeHue;
|
||||
/** Optional className applied to the outer container. */
|
||||
className?: string;
|
||||
/** Optional inline style applied to the outer container (e.g. fluid width via `clamp()`). */
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/** Pre-parsed land rings as arrays of {lat, lng} points. */
|
||||
const LANDMASSES: readonly GeoPoint[][] = LAND_RINGS.map((flat) => {
|
||||
const out: GeoPoint[] = [];
|
||||
for (let i = 0; i < flat.length; i += 2) {
|
||||
out.push({ lng: flat[i], lat: flat[i + 1] });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const RADIUS = 285;
|
||||
const CENTER = 300;
|
||||
/** Seconds per full revolution. Slow on purpose so the motion is ambient. */
|
||||
const ROTATION_PERIOD_SECONDS = 140;
|
||||
|
||||
/**
|
||||
* Orthographic projection: turns a (lat, lng) pair into 2D screen
|
||||
* coordinates plus a `z` depth value. Points with `z <= 0` are on the
|
||||
* back hemisphere and should be hidden (or drawn with low opacity).
|
||||
*/
|
||||
function project(lat: number, lng: number, rotationDeg: number) {
|
||||
const phi = (lat * Math.PI) / 180;
|
||||
// Subtract rotation so the globe appears to spin west-to-east.
|
||||
const lambda = ((lng - rotationDeg) * Math.PI) / 180;
|
||||
const cosPhi = Math.cos(phi);
|
||||
const x = cosPhi * Math.sin(lambda);
|
||||
const y = Math.sin(phi);
|
||||
const z = cosPhi * Math.cos(lambda);
|
||||
return {
|
||||
x: CENTER + x * RADIUS,
|
||||
// Negate so positive latitudes render upward in SVG.
|
||||
y: CENTER - y * RADIUS,
|
||||
z,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Slowly-rotating SVG globe rendered with pure SVG (no WebGL, no canvas).
|
||||
*
|
||||
* Visuals are intentionally warm and hand-drawn rather than satellite/HUD:
|
||||
* - a soft cream sphere lit from the upper-left,
|
||||
* - sandy-amber landmasses (real Natural Earth continent shapes,
|
||||
* pre-simplified to ~1.5k vertices), and
|
||||
* - small glowing marker dots for active campaigns.
|
||||
*
|
||||
* Rotation is driven by `requestAnimationFrame` and applied imperatively via
|
||||
* refs so the component never re-renders during animation. Respects
|
||||
* `prefers-reduced-motion` by holding at a static angle.
|
||||
*/
|
||||
export function HeroGlobe({
|
||||
markers = [],
|
||||
selectedKey = null,
|
||||
onMarkerClick,
|
||||
hue = HOPE_PALETTE[0],
|
||||
className,
|
||||
style,
|
||||
}: HeroGlobeProps) {
|
||||
const landRef = useRef<SVGGElement | null>(null);
|
||||
const markersRef = useRef<SVGGElement | null>(null);
|
||||
|
||||
// Stable per-ring point counts so the animation loop knows how many polygon
|
||||
// elements to update without re-reading the DOM each frame.
|
||||
const ringSizes = useMemo(() => LANDMASSES.map((r) => r.length), []);
|
||||
|
||||
// Live refs so the rAF loop can read the latest markers / selection
|
||||
// without retriggering the effect — otherwise every spotlight tick
|
||||
// would tear down the loop and snap rotation back to 0°.
|
||||
const markersRefValue = useRef(markers);
|
||||
const selectedKeyRef = useRef(selectedKey);
|
||||
useEffect(() => {
|
||||
markersRefValue.current = markers;
|
||||
selectedKeyRef.current = selectedKey;
|
||||
}, [markers, selectedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
let rafId = 0;
|
||||
let start: number | null = null;
|
||||
|
||||
const tick = (timestamp: number) => {
|
||||
if (start === null) start = timestamp;
|
||||
const elapsedSeconds = (timestamp - start) / 1000;
|
||||
const rotation = prefersReducedMotion
|
||||
? 25 // Hold at a flattering static angle.
|
||||
: (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;
|
||||
for (let i = 0; i < LANDMASSES.length; i++) {
|
||||
const ring = LANDMASSES[i];
|
||||
const polygon = polygons[i] as SVGPolygonElement | undefined;
|
||||
if (!polygon) continue;
|
||||
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
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(' '));
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Campaign markers ---
|
||||
const markersEl = markersRef.current;
|
||||
const liveMarkers = markersRefValue.current;
|
||||
const liveSelectedKey = selectedKeyRef.current;
|
||||
if (markersEl) {
|
||||
const groups = markersEl.children;
|
||||
for (let i = 0; i < liveMarkers.length; i++) {
|
||||
const m = liveMarkers[i];
|
||||
const group = groups[i] as SVGGElement | undefined;
|
||||
if (!group) continue;
|
||||
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;
|
||||
}
|
||||
// Selected marker scales up subtly to read as "you are here".
|
||||
const scale = m.key === liveSelectedKey ? 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));
|
||||
}
|
||||
}
|
||||
|
||||
if (!prefersReducedMotion) {
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
// `markers` and `selectedKey` are read inside `tick` via refs above,
|
||||
// so we deliberately omit them from this dep list to keep the
|
||||
// rotation loop alive across spotlight cycles.
|
||||
}, [ringSizes]);
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{/* Wrapper so the outer halo can sit behind the SVG. The halo is a
|
||||
plain div (not part of the SVG) so its blur extends past the
|
||||
sphere without needing a giant viewBox, and so we can drive it
|
||||
with a CSS keyframe animation independent of the rotation. */}
|
||||
<div className="relative size-full">
|
||||
{/* Outer atmospheric halo. Scaled larger than the wrapper so light
|
||||
spills out into the photo, blurred for softness, and tinted
|
||||
with the active campaign's hopeful hue. Breathes slowly via
|
||||
the .hero-globe-halo-breath class defined in index.css. */}
|
||||
<div
|
||||
className="hero-globe-halo-breath absolute inset-[-15%] pointer-events-none"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(closest-side, ${hue.glow} 0%, ${hue.rim} 30%, transparent 70%)`,
|
||||
filter: 'blur(40px)',
|
||||
// background-image isn't actually transitionable across
|
||||
// gradient stops in CSS, but keeping the declaration here
|
||||
// documents that the hue swap is driven by React re-renders
|
||||
// synced to the HeroAtmosphere crossfade.
|
||||
}}
|
||||
/>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 600 600"
|
||||
className="relative size-full"
|
||||
role="img"
|
||||
aria-label="Globe showing locations of active fundraising campaigns"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
{/* Sphere base: warm dawn gold lit from the upper-left, fading
|
||||
into a deeper honey shadow on the lower-right. The whole
|
||||
sphere is meant to read as "lit from within" — like the
|
||||
moment before sunrise — not as a slab of dirt. */}
|
||||
<radialGradient id="hero-globe-base" cx="32%" cy="28%" r="78%">
|
||||
<stop offset="0%" stopColor="hsl(46 100% 96% / 0.92)" />
|
||||
<stop offset="40%" stopColor="hsl(38 90% 82% / 0.82)" />
|
||||
<stop offset="100%" stopColor="hsl(28 65% 60% / 0.72)" />
|
||||
</radialGradient>
|
||||
{/* Back-lit limb light. Reads as light pooling on the inside of
|
||||
the sphere edge — Earthrise rather than satellite. Tinted
|
||||
with the active hopeful hue, kept narrow + low-opacity so it
|
||||
feels like atmosphere, not a neon ring. */}
|
||||
<radialGradient id="hero-globe-rim" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="86%" stopColor={hue.rim} stopOpacity="0" />
|
||||
<stop offset="97%" stopColor={hue.rim} stopOpacity="0.55" />
|
||||
<stop offset="100%" stopColor={hue.glow} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
{/* Soft highlight in the upper-left to sell the sphere shape. */}
|
||||
<radialGradient id="hero-globe-highlight" cx="30%" cy="25%" r="35%">
|
||||
<stop offset="0%" stopColor="hsl(50 100% 98% / 0.58)" />
|
||||
<stop offset="100%" stopColor="hsl(50 100% 98% / 0)" />
|
||||
</radialGradient>
|
||||
{/* 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.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
|
||||
terminator don't leak outside the circle. */}
|
||||
<clipPath id="hero-globe-clip">
|
||||
<circle cx={CENTER} cy={CENTER} r={RADIUS} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
{/* Base sphere with light shading. */}
|
||||
<circle cx={CENTER} cy={CENTER} r={RADIUS} fill="url(#hero-globe-base)" />
|
||||
|
||||
{/* Landmasses, clipped to the sphere. */}
|
||||
<g clipPath="url(#hero-globe-clip)">
|
||||
<g
|
||||
ref={landRef}
|
||||
fill="hsl(30 55% 52%)"
|
||||
stroke="hsl(28 50% 40% / 0.25)"
|
||||
strokeWidth="0.3"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{LANDMASSES.map((_, i) => (
|
||||
<polygon key={i} opacity={0} />
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Warm highlight + rim shading sit above the land so the sphere
|
||||
still reads as a lit ball, not a flat map. */}
|
||||
<circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="url(#hero-globe-highlight)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="url(#hero-globe-rim)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* 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.
|
||||
|
||||
On the Discover page the same `<g>` slots are reused for
|
||||
community and country-pulse markers, distinguished by `m.kind`
|
||||
and rendered with a softer glyph + halo so campaigns stay the
|
||||
visual lead. */}
|
||||
<g ref={markersRef}>
|
||||
{markers.map((m) => {
|
||||
const isSelected = m.key === selectedKey;
|
||||
const kind: GlobeMarkerKind = m.kind ?? 'campaign';
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{kind === 'campaign' ? (
|
||||
<>
|
||||
{/* 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)" />
|
||||
</>
|
||||
) : kind === 'community' ? (
|
||||
<>
|
||||
{/* Community: a softly-glowing ring. Reads as a circle of
|
||||
people, gathered. Smaller than the heart so campaigns
|
||||
stay the dominant signal. */}
|
||||
<circle r={10} fill="url(#hero-marker-glow)" />
|
||||
<circle
|
||||
r={4.2}
|
||||
fill="hsl(40 100% 96% / 0.92)"
|
||||
stroke="hsl(28 65% 45% / 0.55)"
|
||||
strokeWidth="0.7"
|
||||
/>
|
||||
<circle r={1.4} fill="hsl(28 70% 50%)" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Country pulse: tiny warm sun-dot, no halo button feel.
|
||||
These are decorative — they trace where the world is
|
||||
currently posting without inviting interaction. */}
|
||||
<circle r={6} fill="url(#hero-marker-glow)" opacity={0.65} />
|
||||
<circle r={1.8} fill="hsl(38 100% 70%)" />
|
||||
</>
|
||||
)}
|
||||
{/* Transparent hit target — much easier to click/tap than the
|
||||
tiny visible glyph, especially on touch. */}
|
||||
<circle
|
||||
r={14}
|
||||
fill="transparent"
|
||||
style={{ cursor: onMarkerClick ? 'pointer' : 'default' }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { memo, useId, useMemo } from 'react';
|
||||
|
||||
import { LAND_RINGS } from '@/lib/landPolygons';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Decorative dark world map with glowing brand-orange Lightning arcs and
|
||||
* pulsing city nodes. Designed as a hero backdrop on near-black surfaces:
|
||||
* type sits comfortably over it without any text shadow.
|
||||
*
|
||||
* Composition (back to front):
|
||||
* 1. Equirectangular world map drawn from {@link LAND_RINGS} — barely
|
||||
* lit so it reads as texture, not focus.
|
||||
* 2. Central radial glow tinted in brand orange behind the visual
|
||||
* center of gravity.
|
||||
* 3. Curated set of arcs between major cities, drawn as quadratic
|
||||
* Bézier paths with a flowing dash animation (the "lightning" hops).
|
||||
* 4. Pulsing dot at every endpoint, with a soft halo.
|
||||
*
|
||||
* The data is intentionally curated — no campaign coupling. The map is a
|
||||
* brand visual, not a state visualization. Arc list lives at the bottom
|
||||
* of this file and can be swapped freely without touching layout.
|
||||
*
|
||||
* Pure SVG, no WebGL, no canvas. ~12 arcs + ~150 polygons — render cost
|
||||
* is negligible. Animations honor `prefers-reduced-motion`.
|
||||
*/
|
||||
function HeroLightningMapImpl({ className }: { className?: string }) {
|
||||
const uid = useId();
|
||||
const arcId = (key: string) => `${uid}-${key}`;
|
||||
|
||||
// viewBox is the equirectangular world: 360 wide × 180 tall, recentered
|
||||
// so (0,0) is the geographic origin. We project [lng, lat] -> [lng, -lat]
|
||||
// (SVG y grows downward).
|
||||
const W = 360;
|
||||
const H = 180;
|
||||
|
||||
const landPaths = useMemo(() => {
|
||||
return LAND_RINGS.map((ring, idx) => {
|
||||
// Rings are flat [lng, lat, lng, lat, ...]. Convert to an SVG path.
|
||||
//
|
||||
// Antimeridian handling: a few rings (notably Russia and Antarctica
|
||||
// in the Natural Earth source) cross the ±180° seam. The data stores
|
||||
// those rings as a single polygon whose longitude jumps from +180 to
|
||||
// -180 (or vice versa) in one step. Drawn naively with a continuous
|
||||
// `L` command, that jump renders as a long horizontal slash spanning
|
||||
// the whole equirectangular viewBox — the "two lines" sitting at
|
||||
// ~lat 41 and ~lat 77 across the map are exactly Russia's bounding
|
||||
// edges drawn by such a connection.
|
||||
//
|
||||
// Detect any longitude step > 180° and close + restart the subpath
|
||||
// with `M` instead, so the two halves of the country render in their
|
||||
// actual hemispheres without a connecting line through the middle.
|
||||
let d = '';
|
||||
let prevLng: number | null = null;
|
||||
for (let i = 0; i < ring.length; i += 2) {
|
||||
const lng = ring[i];
|
||||
const lat = ring[i + 1];
|
||||
const isFirst = i === 0;
|
||||
const wraps = prevLng !== null && Math.abs(lng - prevLng) > 180;
|
||||
const cmd = isFirst || wraps ? 'M' : 'L';
|
||||
d += `${cmd}${lng.toFixed(2)} ${(-lat).toFixed(2)}`;
|
||||
prevLng = lng;
|
||||
}
|
||||
d += 'Z';
|
||||
return <path key={idx} d={d} />;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// All endpoints across the curated arc set, deduplicated, so we render
|
||||
// one pulsing node per city even if multiple arcs share it.
|
||||
const nodes = useMemo(() => {
|
||||
const seen = new Map<string, { lng: number; lat: number }>();
|
||||
for (const arc of CURATED_ARCS) {
|
||||
const a = `${arc.from[0]},${arc.from[1]}`;
|
||||
const b = `${arc.to[0]},${arc.to[1]}`;
|
||||
if (!seen.has(a)) seen.set(a, { lng: arc.from[0], lat: arc.from[1] });
|
||||
if (!seen.has(b)) seen.set(b, { lng: arc.to[0], lat: arc.to[1] });
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('absolute inset-0 overflow-hidden pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Central radial brand-orange glow. Sits behind the map texture so
|
||||
the map reads as illuminated by it, not pasted over it. Position
|
||||
biased slightly right so the headline column on the left stays
|
||||
on the cooler side of the glow. */}
|
||||
<div
|
||||
className="absolute -inset-[10%]"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(60% 55% at 62% 45%, hsl(24 100% 55% / 0.12) 0%, hsl(24 95% 50% / 0.07) 28%, hsl(220 30% 8% / 0) 65%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<svg
|
||||
viewBox={`-${W / 2} -${H / 2} ${W} ${H}`}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
>
|
||||
<defs>
|
||||
{/* Land fill — brand-orange wash, fully opaque. The transparency
|
||||
lives on the wrapping <g opacity=…> below so that overlapping
|
||||
country polygons don't stack their alpha at shared borders
|
||||
(which is what painted the visible "latitude line" along the
|
||||
equator and other country seams). */}
|
||||
<linearGradient id={arcId('land')} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="hsl(24 80% 50%)" />
|
||||
<stop offset="100%" stopColor="hsl(24 70% 45%)" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Arc gradient — bright at midpoint, fading at endpoints, so
|
||||
the line reads as energy traveling rather than a solid wire. */}
|
||||
<linearGradient id={arcId('arc')} x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="hsl(24 100% 60%)" stopOpacity="0.0" />
|
||||
<stop offset="35%" stopColor="hsl(24 100% 60%)" stopOpacity="0.85" />
|
||||
<stop offset="65%" stopColor="hsl(30 100% 65%)" stopOpacity="0.85" />
|
||||
<stop offset="100%" stopColor="hsl(30 100% 65%)" stopOpacity="0.0" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Glow filter for arcs and nodes — wider and softer than a CSS
|
||||
shadow, and crucially, applied inside the SVG so it scales
|
||||
cleanly with the viewBox. */}
|
||||
<filter id={arcId('glow')} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="1.2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Stronger glow for the nodes themselves so they punch through
|
||||
the arcs at intersections. */}
|
||||
<radialGradient id={arcId('node-halo')}>
|
||||
<stop offset="0%" stopColor="hsl(30 100% 70%)" stopOpacity="0.9" />
|
||||
<stop offset="40%" stopColor="hsl(24 100% 55%)" stopOpacity="0.45" />
|
||||
<stop offset="100%" stopColor="hsl(24 100% 50%)" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{/* Land. Each country is its own ring; rendered as separate paths
|
||||
with semi-transparent fill, every shared country border doubles
|
||||
up where polygons overlap. The most jarring of those overlaps
|
||||
falls along the equator (Kenya/Tanzania, DRC/Angola, Indonesian
|
||||
islands) and reads as a horizontal "latitude line."
|
||||
|
||||
Fix: paint each country with a fully-opaque fill, then put the
|
||||
transparency on the wrapping <g opacity=…>. SVG group opacity
|
||||
renders the children into an offscreen buffer first and then
|
||||
composites the buffer at the group's alpha, so internal overlaps
|
||||
don't stack. No stroke for the same reason. */}
|
||||
<g
|
||||
fill={`url(#${arcId('land')})`}
|
||||
stroke="none"
|
||||
opacity="0.18"
|
||||
>
|
||||
{landPaths}
|
||||
</g>
|
||||
|
||||
{/* Arcs. Each arc is a quadratic Bézier with the control point
|
||||
lifted above the great-circle path, giving the curved silhouette
|
||||
from the reference. Stroke-dasharray + animated stroke-dashoffset
|
||||
produces the flowing-energy effect.
|
||||
|
||||
`vector-effect="non-scaling-stroke"` keeps the stroke at a fixed
|
||||
pixel width regardless of viewBox-to-screen scale, which is what
|
||||
kills the line jitter — without it, sub-pixel stroke widths in
|
||||
user-space combine with the SVG glow filter to shimmer at any
|
||||
responsive size. */}
|
||||
<g
|
||||
fill="none"
|
||||
stroke={`url(#${arcId('arc')})`}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
filter={`url(#${arcId('glow')})`}
|
||||
>
|
||||
{CURATED_ARCS.map((arc, i) => {
|
||||
const [x1, y1] = [arc.from[0], -arc.from[1]];
|
||||
const [x2, y2] = [arc.to[0], -arc.to[1]];
|
||||
// Lift the control point above the chord, scaled with chord
|
||||
// length so short arcs stay tight and trans-oceanic arcs
|
||||
// sweep dramatically.
|
||||
const len = Math.hypot(x2 - x1, y2 - y1);
|
||||
const lift = Math.min(60, len * 0.42);
|
||||
const mx = (x1 + x2) / 2;
|
||||
const my = (y1 + y2) / 2;
|
||||
// Push the lift toward whichever hemisphere the chord midpoint
|
||||
// already favors, so equator-crossing arcs sweep clearly into
|
||||
// their dominant hemisphere instead of all stacking through
|
||||
// y=0. Pure equatorial midpoints (my≈0) default to lifting
|
||||
// north (negative y in SVG space).
|
||||
const direction = my > 0 ? 1 : -1;
|
||||
const cx = mx;
|
||||
const cy = my + lift * direction;
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={`M${x1.toFixed(2)} ${y1.toFixed(2)} Q${cx.toFixed(2)} ${cy.toFixed(2)} ${x2.toFixed(2)} ${y2.toFixed(2)}`}
|
||||
className="hero-arc-flow"
|
||||
style={{
|
||||
// Stagger each arc's animation so the flow feels
|
||||
// organic, not lockstep.
|
||||
animationDelay: `${(i * 0.43).toFixed(2)}s`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* City nodes. Two layers per node: a soft halo behind, a hot dot
|
||||
in front. Halo is what reads at distance; dot is what reads up
|
||||
close. */}
|
||||
<g>
|
||||
{nodes.map((n, i) => {
|
||||
const x = n.lng;
|
||||
const y = -n.lat;
|
||||
return (
|
||||
<g key={i}>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="2.2"
|
||||
fill={`url(#${arcId('node-halo')})`}
|
||||
className="hero-node-pulse"
|
||||
style={{ animationDelay: `${(i * 0.31).toFixed(2)}s` }}
|
||||
/>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="0.55"
|
||||
fill="hsl(36 100% 70%)"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const HeroLightningMap = memo(HeroLightningMapImpl);
|
||||
|
||||
/**
|
||||
* Hand-picked arcs between major activist hubs across continents. Order
|
||||
* matters only for the staggered animation start times — pairs are
|
||||
* otherwise independent. Coordinates are `[lng, lat]` in degrees.
|
||||
*
|
||||
* Edit freely. Twelve arcs is roughly the sweet spot — fewer feels
|
||||
* sparse, more turns into a tangle that competes with the headline.
|
||||
*/
|
||||
const CURATED_ARCS: ReadonlyArray<{
|
||||
from: readonly [number, number];
|
||||
to: readonly [number, number];
|
||||
}> = [
|
||||
// Trans-Atlantic — North America ↔ Europe / Africa
|
||||
{ from: [-74.0, 40.7], to: [-0.1, 51.5] }, // New York → London
|
||||
{ from: [-122.4, 37.8], to: [13.4, 52.5] }, // San Francisco → Berlin
|
||||
{ from: [-79.4, 43.7], to: [2.35, 48.9] }, // Toronto → Paris
|
||||
{ from: [-0.1, 51.5], to: [3.4, 6.5] }, // London → Lagos
|
||||
// Trans-Pacific — Americas ↔ Asia / Oceania
|
||||
{ from: [-118.2, 34.0], to: [139.7, 35.7] }, // Los Angeles → Tokyo
|
||||
{ from: [-99.1, 19.4], to: [121.5, 25.0] }, // Mexico City → Taipei
|
||||
{ from: [151.2, -33.9], to: [-122.4, 37.8] }, // Sydney → San Francisco
|
||||
// South America bridges
|
||||
{ from: [-58.4, -34.6], to: [-43.2, -22.9] }, // Buenos Aires → Rio
|
||||
{ from: [-43.2, -22.9], to: [3.4, 6.5] }, // Rio → Lagos
|
||||
// Asia / Africa lattice
|
||||
{ from: [77.2, 28.6], to: [55.3, 25.3] }, // Delhi → Dubai
|
||||
{ from: [55.3, 25.3], to: [31.2, 30.0] }, // Dubai → Cairo
|
||||
{ from: [31.2, 30.0], to: [13.4, 52.5] }, // Cairo → Berlin
|
||||
{ from: [103.8, 1.35], to: [121.5, 25.0] }, // Singapore → Taipei
|
||||
{ from: [18.4, -33.9], to: [3.4, 6.5] }, // Cape Town → Lagos
|
||||
];
|
||||
@@ -95,6 +95,8 @@ export interface ProfileCardProps {
|
||||
onRemoveAvatar?: () => void;
|
||||
/** Show NIP-05 row (default true) */
|
||||
showNip05?: boolean;
|
||||
/** Show NIP-58 badge showcase row (default true). */
|
||||
showBadges?: boolean;
|
||||
/** When provided, render an editable profile fields section below bio */
|
||||
extraFields?: ProfileField[];
|
||||
onExtraFieldsChange?: (fields: ProfileField[]) => void;
|
||||
@@ -107,6 +109,7 @@ export function ProfileCard({
|
||||
onPickImage,
|
||||
onRemoveAvatar,
|
||||
showNip05 = true,
|
||||
showBadges = true,
|
||||
extraFields,
|
||||
onExtraFieldsChange,
|
||||
}: ProfileCardProps) {
|
||||
@@ -332,7 +335,7 @@ export function ProfileCard({
|
||||
</div>
|
||||
|
||||
{/* Badge showcase */}
|
||||
{(badgeRefs.length > 0 || badgesLoading) && (
|
||||
{showBadges && (badgeRefs.length > 0 || badgesLoading) && (
|
||||
<div className="px-4 pb-3">
|
||||
<BadgeShowcaseGrid
|
||||
items={badgeRefs.map((ref) => ({
|
||||
|
||||
@@ -82,6 +82,16 @@ interface ProfileRightSidebarProps {
|
||||
onMediaClick?: (url: string) => void;
|
||||
/** Override the root element's className (e.g. to show on mobile). */
|
||||
className?: string;
|
||||
/**
|
||||
* Layout variant.
|
||||
*
|
||||
* - `'rail'` (default) — legacy 1/4-width fixed sidebar with full-height
|
||||
* sticky scrolling. Designed for the old MainLayout shell.
|
||||
* - `'inline'` — fills its parent's width with no positioning, so it can
|
||||
* be slotted into a grid cell or another container that manages
|
||||
* layout (e.g. the ProfilePage two-column body).
|
||||
*/
|
||||
variant?: 'rail' | 'inline';
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
@@ -495,7 +505,7 @@ function sidebarJustifiedLayout(items: MediaItem[]): { items: MediaItem[]; heigh
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }: ProfileRightSidebarProps) {
|
||||
export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className, variant = 'rail' }: ProfileRightSidebarProps) {
|
||||
const { config } = useAppContext();
|
||||
const { nostr } = useNostr();
|
||||
|
||||
@@ -534,8 +544,12 @@ export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }:
|
||||
|
||||
const sidebarRows = useMemo(() => sidebarJustifiedLayout(media), [media]);
|
||||
|
||||
const rootClass = variant === 'inline'
|
||||
? 'flex flex-col w-full'
|
||||
: 'w-1/4 max-w-[300px] shrink-0 hidden lg:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3';
|
||||
|
||||
return (
|
||||
<aside className={cn("w-1/4 max-w-[300px] shrink-0 hidden lg:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3", className)}>
|
||||
<aside className={cn(rootClass, className)}>
|
||||
{/* Media Section — only shown when pubkey prop is provided */}
|
||||
{pubkey !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<h2 className="text-xl font-bold mb-3" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Media</h2>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CommunityMiniCard } from '@/components/discovery/CommunityMiniCard';
|
||||
import type { ProfileOrganization } from '@/hooks/useProfileOrganizations';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface OrganizationsAllDialogProps {
|
||||
/** Full list of organizations to surface in the overflow dialog. */
|
||||
orgs: ProfileOrganization[];
|
||||
/** Trigger element — typically a "See all N →" button. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* "See all organizations" dialog used by the profile identity rail.
|
||||
*
|
||||
* Renders every org the profile is associated with in a scrollable
|
||||
* 2-column grid of CommunityMiniCards with role badges. Lifted out of
|
||||
* the (now-deleted) ProfileOrganizationsStrip so the rail can use it
|
||||
* directly without pulling in the strip's full file.
|
||||
*/
|
||||
export function OrganizationsAllDialog({ orgs, children }: OrganizationsAllDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Users className="size-5 text-primary" />
|
||||
All organizations
|
||||
<span className="text-sm font-normal text-muted-foreground">({orgs.length})</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="px-6 py-4 flex-1">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{orgs.map((entry) => (
|
||||
<div key={entry.community.aTag} 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-[10px] font-semibold uppercase tracking-wide',
|
||||
entry.isFounder ? 'text-primary' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{entry.isFounder ? 'Founder' : 'Moderator'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="px-6 pb-6 pt-2 flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { Loader2, Sparkles } from 'lucide-react';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { useAgoraFeed } from '@/hooks/useAgoraFeed';
|
||||
|
||||
interface ProfileActivityTabProps {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified profile feed scoped to one author.
|
||||
*
|
||||
* Pipes {@link useAgoraFeed} through with `authors=[pubkey]` AND
|
||||
* `includeAuthorNotes: true`, so the relay-side filter pulls in:
|
||||
*
|
||||
* - Agora-marked content (campaigns, pledges, communities, marked notes,
|
||||
* Agora-rooted comments, donation receipts), and
|
||||
* - every kind 1 / 6 note this author has published, regardless of the
|
||||
* `t:agora` marker.
|
||||
*
|
||||
* The two sources merge into a single chronological timeline so the
|
||||
* profile shows "everything this person has done on the network." Replaces
|
||||
* the previous separate Activity + Posts tabs.
|
||||
*
|
||||
* Single-column inside the tab area because the timeline is mixed-kind
|
||||
* and benefits from full-width cards.
|
||||
*/
|
||||
export function ProfileActivityTab({ pubkey, displayName }: ProfileActivityTabProps) {
|
||||
const {
|
||||
events,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useAgoraFeed(true, { authors: [pubkey], includeAuthorNotes: true });
|
||||
|
||||
const { ref: scrollRef, inView } = useInView({ threshold: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (isLoading && events.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6 space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i} className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-12">
|
||||
<Card className="border-dashed">
|
||||
<div className="py-12 px-8 text-center">
|
||||
<Sparkles className="size-10 mx-auto mb-3 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
No activity from {displayName} yet. Posts, campaigns, pledges,
|
||||
and donations all show up here.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="divide-y divide-border">
|
||||
{events.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
{hasNextPage && (
|
||||
<div ref={scrollRef} className="flex justify-center py-6">
|
||||
{isFetchingNextPage && (
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Megaphone } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import {
|
||||
extractOnchainZapTxid,
|
||||
verifyOnchainZap,
|
||||
} from '@/hooks/useOnchainZaps';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
|
||||
interface ProfileCampaignsTabProps {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
isOwnProfile: boolean;
|
||||
campaigns: ParsedCampaign[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
type SortMode = 'top' | 'new';
|
||||
|
||||
/**
|
||||
* Full grid of every campaign authored by this profile.
|
||||
*
|
||||
* Owner / moderator can toggle "Show hidden" to see campaigns the
|
||||
* moderation pack has hidden from the home page — visitors only see
|
||||
* non-hidden campaigns by default. Sort modes mirror
|
||||
* {@link AllCampaignsPage}: New (newest created_at first, the default
|
||||
* incoming order) and Top (most sats raised, requires the verified
|
||||
* donation totals).
|
||||
*/
|
||||
export function ProfileCampaignsTab({
|
||||
pubkey,
|
||||
displayName,
|
||||
isOwnProfile,
|
||||
campaigns,
|
||||
isLoading,
|
||||
}: ProfileCampaignsTabProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderation } = useCampaignModeration();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isModerator = !!user && (moderators ?? []).includes(user.pubkey);
|
||||
|
||||
const [sortMode, setSortMode] = useState<SortMode>('new');
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
|
||||
const canShowHidden = isOwnProfile || isModerator;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (canShowHidden && showHidden) return campaigns;
|
||||
return campaigns.filter((c) => !moderation.hiddenCoords.has(c.aTag));
|
||||
}, [campaigns, canShowHidden, showHidden, moderation.hiddenCoords]);
|
||||
|
||||
if (isLoading && filtered.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<CampaignCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-12" data-pubkey={pubkey}>
|
||||
<Card className="border-dashed">
|
||||
<div className="py-12 px-8 text-center">
|
||||
<Megaphone className="size-10 mx-auto mb-3 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
{isOwnProfile
|
||||
? "You haven't launched a campaign yet."
|
||||
: `${displayName} hasn't launched a campaign yet.`}
|
||||
</p>
|
||||
{isOwnProfile && (
|
||||
<Link
|
||||
to="/campaigns/new"
|
||||
className="inline-block mt-4 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Start a campaign →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filtered.length} {filtered.length === 1 ? 'campaign' : 'campaigns'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={sortMode === 'new' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSortMode('new')}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortMode === 'top' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSortMode('top')}
|
||||
>
|
||||
Top
|
||||
</Button>
|
||||
{canShowHidden && (
|
||||
<Button
|
||||
variant={showHidden ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setShowHidden((v) => !v)}
|
||||
>
|
||||
{showHidden ? 'Hide hidden' : 'Show hidden'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortMode === 'top' ? (
|
||||
<SortedByTopGrid campaigns={filtered} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{filtered.map((c) => (
|
||||
<CampaignCard key={c.aTag} campaign={c} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts the visible campaigns by verified sats raised (descending) by
|
||||
* fanning out one receipts query + per-receipt verification across all
|
||||
* campaigns at once. Uses `useQueries`, so the hook call count is
|
||||
* deterministic per render (one queries-tuple, not one hook per campaign)
|
||||
* and the rules of hooks hold.
|
||||
*
|
||||
* Caches share keys with `useCampaignDonations` so the verifier results
|
||||
* are reused across the profile and any other view of the same campaign.
|
||||
*/
|
||||
function SortedByTopGrid({ campaigns }: { campaigns: ParsedCampaign[] }) {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const { esploraApis } = config;
|
||||
|
||||
// Only on-chain campaigns can have verifiable totals. SP campaigns sort to 0.
|
||||
const onchain = campaigns.filter((c) => c.wallet?.mode === 'onchain');
|
||||
|
||||
// Step 1: one receipts query per on-chain campaign.
|
||||
const receiptsQueries = useQueries({
|
||||
queries: onchain.map((campaign) => ({
|
||||
queryKey: ['campaign-donations', 'events', campaign.aTag],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }): Promise<NostrEvent[]> => {
|
||||
return nostr.query(
|
||||
[{ kinds: [8333], '#a': [campaign.aTag], limit: 500 }],
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
staleTime: 15_000,
|
||||
})),
|
||||
});
|
||||
|
||||
// Step 2: dedupe receipts by txid (earliest wins, matching useCampaignDonations).
|
||||
const verificationInputs: Array<{ aTag: string; wallet: string; event: NostrEvent }> = [];
|
||||
for (let i = 0; i < onchain.length; i++) {
|
||||
const campaign = onchain[i];
|
||||
const wallet = campaign.wallet?.value;
|
||||
if (!wallet) continue;
|
||||
const receipts = receiptsQueries[i]?.data ?? [];
|
||||
const ascending = [...receipts].sort((a, b) => a.created_at - b.created_at);
|
||||
const seenTxids = new Set<string>();
|
||||
for (const event of ascending) {
|
||||
const txid = extractOnchainZapTxid(event);
|
||||
if (!txid || seenTxids.has(txid)) continue;
|
||||
seenTxids.add(txid);
|
||||
verificationInputs.push({ aTag: campaign.aTag, wallet, event });
|
||||
}
|
||||
}
|
||||
|
||||
const verifications = useQueries({
|
||||
queries: verificationInputs.map(({ wallet, event }) => ({
|
||||
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, wallet],
|
||||
queryFn: () => verifyOnchainZap(event, esploraApis, wallet),
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
});
|
||||
|
||||
// Step 3: sum verified sats per campaign aTag.
|
||||
const totalsByCoord = new Map<string, number>();
|
||||
for (let i = 0; i < verifications.length; i++) {
|
||||
const { aTag } = verificationInputs[i];
|
||||
const sats = verifications[i].data?.amountSats ?? 0;
|
||||
totalsByCoord.set(aTag, (totalsByCoord.get(aTag) ?? 0) + sats);
|
||||
}
|
||||
|
||||
const sorted = [...campaigns].sort(
|
||||
(a, b) => (totalsByCoord.get(b.aTag) ?? 0) - (totalsByCoord.get(a.aTag) ?? 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{sorted.map((campaign) => (
|
||||
<CampaignCard key={campaign.aTag} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link, Link as RouterLink } from 'react-router-dom';
|
||||
import {
|
||||
Bitcoin,
|
||||
CalendarClock,
|
||||
Globe,
|
||||
HandHeart,
|
||||
Megaphone,
|
||||
MoreHorizontal,
|
||||
QrCode,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
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 { Card } from '@/components/ui/card';
|
||||
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 { ProfileReactionButton } from '@/components/ProfileReactionButton';
|
||||
import { OrganizationsAllDialog } from '@/components/profile/OrganizationsAllDialog';
|
||||
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';
|
||||
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
|
||||
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
|
||||
import { getGeoDisplayName } from '@/lib/countries';
|
||||
|
||||
export 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;
|
||||
/** Pledges (kind 36639) created by this profile. */
|
||||
pledgesCount: number;
|
||||
/**
|
||||
* 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,
|
||||
pledgesCount,
|
||||
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.wallet?.mode === '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`. */}
|
||||
<AvatarBlock
|
||||
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">
|
||||
{/* 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; Pledges + Raised below. */}
|
||||
<StatList
|
||||
followersCount={followersCount}
|
||||
followingCount={followingCount}
|
||||
pledgesCount={pledgesCount}
|
||||
totalRaisedSats={campaignStats.totalRaisedSats}
|
||||
btcPrice={btcPrice}
|
||||
onFollowersOpen={onFollowersOpen}
|
||||
onFollowingOpen={onFollowingOpen}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
|
||||
{/* 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 above the orgs/fields sections. */}
|
||||
{campaigns.length === 0 && pledges.length > 0 && (
|
||||
<RailLatestPledgeSection
|
||||
pledges={pledges}
|
||||
btcPrice={btcPrice}
|
||||
showSeeAll={pledges.length > 1}
|
||||
onSeeAll={() => onTabChange('pledges')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Organizations */}
|
||||
<RailOrganizationsSection pubkey={pubkey} />
|
||||
|
||||
{/* Profile fields (rendered upstream) */}
|
||||
{fields.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<RailSectionHeader icon={null} title="Profile" />
|
||||
<div className="space-y-3">{fieldsContent}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Avatar block ────────────────────────────────────────────────────────────
|
||||
|
||||
function AvatarBlock({
|
||||
metadata,
|
||||
displayName,
|
||||
status,
|
||||
onLightbox,
|
||||
}: {
|
||||
metadata: NostrMetadata | undefined;
|
||||
displayName: string;
|
||||
status: { text: string | undefined; url: string | undefined } | undefined;
|
||||
onLightbox: (url: string) => void;
|
||||
}) {
|
||||
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} />
|
||||
<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;
|
||||
}) {
|
||||
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">
|
||||
Edit profile
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full size-10"
|
||||
title="Share follow link"
|
||||
onClick={onFollowQROpen}
|
||||
>
|
||||
<QrCode className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full size-10"
|
||||
onClick={onMoreMenuOpen}
|
||||
title="More options"
|
||||
>
|
||||
<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" />
|
||||
Donate
|
||||
</Button>
|
||||
) : onchainCampaigns.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="rounded-full font-bold gap-1.5">
|
||||
<HandHeart className="size-4" />
|
||||
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">
|
||||
Goal ${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="More options"
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat list ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatList({
|
||||
followersCount,
|
||||
followingCount,
|
||||
pledgesCount,
|
||||
totalRaisedSats,
|
||||
btcPrice,
|
||||
onFollowersOpen,
|
||||
onFollowingOpen,
|
||||
onTabChange,
|
||||
}: {
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
pledgesCount: number;
|
||||
totalRaisedSats: number;
|
||||
btcPrice: number | undefined;
|
||||
onFollowersOpen: () => void;
|
||||
onFollowingOpen: () => void;
|
||||
onTabChange: (id: string) => void;
|
||||
}) {
|
||||
// Secondary stat rows (one per row). Followers / Following live inline at
|
||||
// the top; the campaign count was dropped because the rail's Campaigns
|
||||
// section already displays the user's campaigns directly below.
|
||||
const rows: Array<{
|
||||
icon?: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
onClick?: () => void;
|
||||
show: boolean;
|
||||
}> = [
|
||||
{
|
||||
icon: <HandHeart className="size-3.5 text-primary" />,
|
||||
label: pledgesCount === 1 ? 'Pledge' : 'Pledges',
|
||||
value: formatNumber(pledgesCount),
|
||||
onClick: () => onTabChange('pledges'),
|
||||
show: pledgesCount > 0,
|
||||
},
|
||||
{
|
||||
icon: <Bitcoin className="size-3.5 text-primary" />,
|
||||
label: '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={`${followersCount} followers`}
|
||||
>
|
||||
<span className="font-bold tabular-nums text-foreground">{formatNumber(followersCount)}</span>
|
||||
<span className="text-muted-foreground">Followers</span>
|
||||
</button>
|
||||
)}
|
||||
{followingCount > 0 && (
|
||||
<button
|
||||
onClick={onFollowingOpen}
|
||||
className="flex items-baseline gap-1.5 hover:opacity-80 transition-opacity"
|
||||
title={`${followingCount} following`}
|
||||
>
|
||||
<span className="font-bold tabular-nums text-foreground">{formatNumber(followingCount)}</span>
|
||||
<span className="text-muted-foreground">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 { 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="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="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 ? `See all ${visible.length} campaigns →` : 'View campaigns tab →'}
|
||||
</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;
|
||||
}) {
|
||||
// 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="Latest pledge"
|
||||
/>
|
||||
<RailPledgeCard action={latest} btcPrice={btcPrice} />
|
||||
{showSeeAll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSeeAll}
|
||||
className="text-sm text-primary hover:underline font-medium"
|
||||
>
|
||||
See all {pledges.length} pledges →
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact pledge card sized for the rail's narrow column. Smaller cover
|
||||
* aspect, tighter padding, and a single-line pledged amount that doesn't
|
||||
* dominate the rail.
|
||||
*/
|
||||
function RailPledgeCard({
|
||||
action,
|
||||
btcPrice,
|
||||
}: {
|
||||
action: Action;
|
||||
btcPrice: number | undefined;
|
||||
}) {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 36639,
|
||||
pubkey: action.pubkey,
|
||||
identifier: action.id,
|
||||
});
|
||||
const cover = sanitizeUrl(action.image) ?? DEFAULT_COVER_IMAGE;
|
||||
const deadline = action.deadline ? formatCompactPledgeDeadline(action.deadline) : null;
|
||||
const isExpired = !!deadline?.isPast;
|
||||
const countryLabel = action.countryCode ? getGeoDisplayName(action.countryCode) : undefined;
|
||||
|
||||
return (
|
||||
<RouterLink
|
||||
to={`/${naddr}`}
|
||||
className="group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow group-hover:shadow-md">
|
||||
<div className="relative aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
{isExpired && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute top-2 right-2 backdrop-blur bg-background/85 border-border/40 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Ended
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 space-y-1.5">
|
||||
<h3 className="font-semibold text-sm leading-snug line-clamp-2">{action.title}</h3>
|
||||
<div className="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground uppercase tracking-wide font-semibold">Pledged</span>
|
||||
<span className="text-foreground font-bold tabular-nums">
|
||||
{formatPledgeAmount(action.bounty, btcPrice)}
|
||||
</span>
|
||||
</div>
|
||||
{(countryLabel || deadline) && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground pt-0.5">
|
||||
{deadline && (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1',
|
||||
deadline.isPast && 'text-destructive',
|
||||
)}>
|
||||
<CalendarClock className="size-3" />
|
||||
{deadline.label}
|
||||
</span>
|
||||
)}
|
||||
{countryLabel && <span className="truncate">{countryLabel}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</RouterLink>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Rail Organizations Section ─────────────────────────────────────────────
|
||||
|
||||
function RailOrganizationsSection({ pubkey }: { pubkey: string }) {
|
||||
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="Organizations" />
|
||||
<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="Organizations"
|
||||
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"
|
||||
>
|
||||
See all {orgs.length} →
|
||||
</button>
|
||||
</OrganizationsAllDialog>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RailOrgCell({ entry }: { entry: ProfileOrganization }) {
|
||||
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 ? 'Founder' : '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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { CalendarClock, HandHeart, MapPin } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
|
||||
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
|
||||
import { getGeoDisplayName } from '@/lib/countries';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Action } from '@/hooks/useActions';
|
||||
|
||||
interface ProfilePledgesTabProps {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
isOwnProfile: boolean;
|
||||
/** Pledges authored by this pubkey. Already filtered upstream. */
|
||||
pledges: Action[];
|
||||
/** BTC price for sats↔USD conversion in pledge amount labels. */
|
||||
btcPrice: number | undefined;
|
||||
/** True while the underlying useActions() query is still in flight. */
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pledges authored by this profile, rendered as a responsive grid that
|
||||
* mirrors the `/pledges` (`ActionsPage`) directory styling.
|
||||
*
|
||||
* v1 scope per the design plan: pledges *created* by the user.
|
||||
* "Pledges backed" (zapped submissions on others' pledges) is deferred to v2.
|
||||
*/
|
||||
export function ProfilePledgesTab({
|
||||
pubkey,
|
||||
displayName,
|
||||
isOwnProfile,
|
||||
pledges,
|
||||
btcPrice,
|
||||
isLoading,
|
||||
}: ProfilePledgesTabProps) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Loading skeleton until the first list resolves.
|
||||
if (isLoading && pledges.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6">
|
||||
<PledgesGridSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pledges.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-12">
|
||||
<Card className="border-dashed">
|
||||
<div className="py-12 px-8 text-center">
|
||||
<HandHeart className="size-10 mx-auto mb-3 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
{isOwnProfile
|
||||
? "You haven't created a pledge yet."
|
||||
: `${displayName} hasn't created a pledge yet.`}
|
||||
</p>
|
||||
{isOwnProfile && (
|
||||
<RouterLink
|
||||
to="/pledges/new"
|
||||
className="inline-block mt-4 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Create a pledge →
|
||||
</RouterLink>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split into active vs ended so the active pledges lead the grid.
|
||||
const active: Action[] = [];
|
||||
const ended: Action[] = [];
|
||||
for (const p of pledges) {
|
||||
if (p.deadline && p.deadline <= now) ended.push(p);
|
||||
else active.push(p);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6 space-y-8" data-pubkey={pubkey}>
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
{ended.length > 0 && (
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
|
||||
Active
|
||||
</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{active.map((pledge) => (
|
||||
<ProfilePledgeCard key={pledge.event.id} action={pledge} btcPrice={btcPrice} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{ended.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
|
||||
Ended
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{ended.map((pledge) => (
|
||||
<ProfilePledgeCard key={pledge.event.id} action={pledge} btcPrice={btcPrice} isExpired />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfilePledgeCard({
|
||||
action,
|
||||
isExpired,
|
||||
btcPrice,
|
||||
}: {
|
||||
action: Action;
|
||||
isExpired?: boolean;
|
||||
btcPrice: number | undefined;
|
||||
}) {
|
||||
const [imageLoadFailed, setImageLoadFailed] = useState(false);
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 36639,
|
||||
pubkey: action.pubkey,
|
||||
identifier: action.id,
|
||||
});
|
||||
|
||||
const coverImage = (action.image && !imageLoadFailed) ? action.image : DEFAULT_COVER_IMAGE;
|
||||
const deadline = action.deadline ? formatCompactPledgeDeadline(action.deadline) : null;
|
||||
const countryLabel = action.countryCode ? getGeoDisplayName(action.countryCode) : undefined;
|
||||
|
||||
return (
|
||||
<RouterLink
|
||||
to={`/${naddr}`}
|
||||
className="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"
|
||||
>
|
||||
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
|
||||
<div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
|
||||
<img
|
||||
src={coverImage}
|
||||
alt=""
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
onError={() => setImageLoadFailed(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
{isExpired && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute top-3 right-3 backdrop-blur bg-background/85 border-border/40 text-muted-foreground"
|
||||
>
|
||||
Ended
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-5 flex-1">
|
||||
<h3 className="font-bold leading-tight tracking-tight text-lg line-clamp-2">
|
||||
{action.title}
|
||||
</h3>
|
||||
{action.description.trim() && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{action.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="rounded-xl border border-primary/20 bg-primary/10 px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary">Pledged</p>
|
||||
<p className="mt-1 text-2xl font-bold tracking-tight text-foreground">
|
||||
{formatPledgeAmount(action.bounty, btcPrice)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MapPin className="size-3.5" />
|
||||
{countryLabel}
|
||||
</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>
|
||||
</Card>
|
||||
</RouterLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PledgesGridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i} className="overflow-hidden border-border/70">
|
||||
<Skeleton className="aspect-[16/9] w-full rounded-none" />
|
||||
<div className="p-5 space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useNavHidden } from '@/contexts/LayoutContext';
|
||||
|
||||
export interface ProfileTabsProps {
|
||||
tabs: Array<{ id: string; label: string }>;
|
||||
activeTab: string;
|
||||
onChange: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile-local tab bar.
|
||||
*
|
||||
* A focused alternative to the global `SubHeaderBar` — no arc decoration,
|
||||
* no hover slice tracking, no FAB-aware spacing. Just a clean horizontal
|
||||
* row with an animated underline marking the active tab. Designed for the
|
||||
* 4-tab profile case (Activity / Campaigns / Pledges / Posts).
|
||||
*
|
||||
* Behavior:
|
||||
* - Sticks to the top of its containing scroll context. The parent column
|
||||
* can place it inside any scroll region; the bar uses `position: sticky`.
|
||||
* - Underline animates between active tabs via a single absolute-positioned
|
||||
* indicator measured from the active tab's offset/width.
|
||||
* - Horizontally scrolls when overflowing; auto-scrolls the active tab into
|
||||
* view on selection (matches the previous TabButton behavior).
|
||||
*/
|
||||
export function ProfileTabs({ tabs, activeTab, onChange }: ProfileTabsProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const [indicator, setIndicator] = useState<{ left: number; width: number } | null>(null);
|
||||
const navHidden = useNavHidden();
|
||||
|
||||
// Measure the active tab and update the underline indicator.
|
||||
useLayoutEffect(() => {
|
||||
const btn = tabRefs.current.get(activeTab);
|
||||
if (!btn) {
|
||||
setIndicator(null);
|
||||
return;
|
||||
}
|
||||
setIndicator({ left: btn.offsetLeft, width: btn.offsetWidth });
|
||||
}, [activeTab, tabs]);
|
||||
|
||||
// Recompute on resize (label-width changes between breakpoints).
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
const btn = tabRefs.current.get(activeTab);
|
||||
if (!btn) return;
|
||||
setIndicator({ left: btn.offsetLeft, width: btn.offsetWidth });
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [activeTab]);
|
||||
|
||||
// Scroll the active tab into view when activated (overflow scroll case).
|
||||
useLayoutEffect(() => {
|
||||
const btn = tabRefs.current.get(activeTab);
|
||||
const track = trackRef.current;
|
||||
if (!btn || !track) return;
|
||||
const left = btn.offsetLeft;
|
||||
const right = left + btn.offsetWidth;
|
||||
const viewLeft = track.scrollLeft;
|
||||
const viewRight = viewLeft + track.clientWidth;
|
||||
if (left < viewLeft) {
|
||||
track.scrollTo({ left: left - 8, behavior: 'smooth' });
|
||||
} else if (right > viewRight) {
|
||||
track.scrollTo({ left: right - track.clientWidth + 8, behavior: 'smooth' });
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Stickiness — sits at the top of the column scroll. `top-mobile-bar`
|
||||
// matches the existing app convention so it sits flush with the
|
||||
// mobile top nav. On desktop the chrome shifts and we use top-0.
|
||||
'sticky top-mobile-bar sidebar:top-0 z-10',
|
||||
// On mobile, fade + slide fully out of view when the user scrolls
|
||||
// down — otherwise the tabs sit at `top-mobile-bar` while the top
|
||||
// bar slides away, leaving a translucent gap above them, and when
|
||||
// the top bar slides back in it visibly crosses over the top of
|
||||
// the tab bar (top bar is z-20, tabs z-10).
|
||||
//
|
||||
// We can't simply use the shared `.nav-hidden-slide` utility (as
|
||||
// the global `SubHeaderBar` does) because the profile tab bar is
|
||||
// notably taller than other sub-headers and visibly gets clipped
|
||||
// by the top bar mid-transition. Pair the slide with an opacity
|
||||
// fade so the bar isn't visibly intersecting the top bar as it
|
||||
// animates.
|
||||
'max-sidebar:transition-[transform,opacity] max-sidebar:duration-300 max-sidebar:ease-in-out',
|
||||
navHidden && 'nav-hidden-slide max-sidebar:opacity-0',
|
||||
// Visual separation — translucent backdrop so feed content doesn't
|
||||
// bleed through, with a single hairline border below.
|
||||
'bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60',
|
||||
'border-b border-border/60',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative flex overflow-x-auto scrollbar-none"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.id === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(tab.id, el);
|
||||
else tabRefs.current.delete(tab.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (active) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0 });
|
||||
onChange(tab.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'relative shrink-0 px-4 py-3.5 text-sm font-medium whitespace-nowrap',
|
||||
'transition-colors duration-150',
|
||||
'focus:outline-none focus-visible:bg-secondary/40 rounded-sm',
|
||||
active
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
aria-selected={active}
|
||||
role="tab"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Active-tab underline indicator. Animates between tab positions. */}
|
||||
{indicator && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute bottom-0 h-0.5 bg-primary rounded-full transition-all duration-200 ease-out"
|
||||
style={{ left: indicator.left, width: indicator.width }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useContentFilters } from '@/hooks/useContentFilters';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { CAMPAIGN_KIND } from '@/lib/campaign';
|
||||
import { getEnabledFeedKinds } from '@/lib/extraKinds';
|
||||
import { getPaginationCursor, shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
|
||||
@@ -131,6 +133,22 @@ export interface UseAgoraFeedOptions {
|
||||
* follows nobody.
|
||||
*/
|
||||
authors?: string[];
|
||||
/**
|
||||
* When true, also include events authored by `authors` in any of the
|
||||
* user's enabled "feed kinds" (notes, reposts, articles, photos,
|
||||
* videos, polls, etc. — see {@link getEnabledFeedKinds}) regardless
|
||||
* of the `t:agora` marker. Produces a unified "everything this person
|
||||
* has done on the network" feed.
|
||||
*
|
||||
* Only meaningful in combination with `authors`; setting it without
|
||||
* `authors` would flood the feed with all kind-1 notes on every relay
|
||||
* and is silently ignored.
|
||||
*
|
||||
* Used by the profile page to merge the legacy Posts tab into the
|
||||
* Activity tab. Off by default so the strict Agora home feed isn't
|
||||
* affected.
|
||||
*/
|
||||
includeAuthorNotes?: boolean;
|
||||
}
|
||||
|
||||
/** Strict Agora activity feed: campaigns, pledges, communities, world posts, #Agora notes, and donations. */
|
||||
@@ -138,6 +156,7 @@ export function useAgoraFeed(enabled: boolean, options?: UseAgoraFeedOptions) {
|
||||
const { nostr } = useNostr();
|
||||
const { muteItems } = useMuteList();
|
||||
const { shouldFilterEvent } = useContentFilters();
|
||||
const { feedSettings } = useFeedSettings();
|
||||
|
||||
const authors = options?.authors;
|
||||
const authorsKey = authors ? [...authors].sort().join(',') : '';
|
||||
@@ -145,9 +164,22 @@ export function useAgoraFeed(enabled: boolean, options?: UseAgoraFeedOptions) {
|
||||
// (e.g. the user follows nobody) — skip the query entirely.
|
||||
const authorsEmpty = authors !== undefined && authors.length === 0;
|
||||
const queryEnabled = enabled && !authorsEmpty;
|
||||
// Author-scoped notes inclusion only makes sense when at least one
|
||||
// author is set; ignore the option otherwise (see option doc).
|
||||
const includeAuthorNotes = !!options?.includeAuthorNotes && !!authors && authors.length > 0;
|
||||
// Pull the user's enabled "feed kinds" — same set the legacy Posts tab
|
||||
// used. Includes notes (1), reposts (6), articles (30023), photos (20),
|
||||
// videos (21/22), polls, etc. — every kind the user opted to see in
|
||||
// mixed feeds. Memoize via stable cache-key so changing settings refetch.
|
||||
const authorNoteKinds = includeAuthorNotes ? getEnabledFeedKinds(feedSettings) : [];
|
||||
// Always include kind 1 / 6 even if the user disabled them in feed
|
||||
// settings — a profile feed without notes is broken.
|
||||
if (includeAuthorNotes && !authorNoteKinds.includes(1)) authorNoteKinds.push(1);
|
||||
if (includeAuthorNotes && !authorNoteKinds.includes(6)) authorNoteKinds.push(6);
|
||||
const authorNoteKindsKey = [...authorNoteKinds].sort((a, b) => a - b).join(',');
|
||||
|
||||
const query = useInfiniteQuery<AgoraFeedPage, Error>({
|
||||
queryKey: ['agora-feed', authorsKey],
|
||||
queryKey: ['agora-feed', authorsKey, includeAuthorNotes, authorNoteKindsKey],
|
||||
queryFn: async ({ pageParam, signal: querySignal }) => {
|
||||
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
|
||||
const until = pageParam as number | undefined;
|
||||
@@ -165,8 +197,35 @@ export function useAgoraFeed(enabled: boolean, options?: UseAgoraFeedOptions) {
|
||||
{ kinds: [NOTE_KIND], '#t': AGORA_T_TAGS, limit: Math.ceil(AGORA_PAGE_SIZE / 2), ...authorsFilter, ...(until && { until }) },
|
||||
];
|
||||
|
||||
// Author-scoped notes — every enabled feed kind from this author,
|
||||
// no `t:agora` requirement. Powers the unified profile feed where
|
||||
// the legacy Posts tab has been folded into Activity. The kind set
|
||||
// mirrors the user's feed settings (notes, reposts, articles,
|
||||
// photos, videos, polls, etc.) so a profile shows everything the
|
||||
// person has done across the network.
|
||||
if (includeAuthorNotes && authorNoteKinds.length > 0) {
|
||||
filters.push({
|
||||
kinds: authorNoteKinds,
|
||||
...authorsFilter,
|
||||
limit: AGORA_PAGE_SIZE,
|
||||
...(until && { until }),
|
||||
});
|
||||
}
|
||||
|
||||
const raw = await nostr.query(filters, { signal });
|
||||
const filtered = raw.filter(isRelevantAgoraEvent);
|
||||
// When author-notes are included, accept any event of an enabled
|
||||
// feed kind authored by one of the requested authors regardless of
|
||||
// the strict Agora gate. The strong author scope is the trust
|
||||
// anchor.
|
||||
const authorSet = new Set(authors ?? []);
|
||||
const authorKindSet = new Set(authorNoteKinds);
|
||||
const filtered = raw.filter((event) => {
|
||||
if (isRelevantAgoraEvent(event)) return true;
|
||||
if (!includeAuthorNotes) return false;
|
||||
if (!authorKindSet.has(event.kind)) return false;
|
||||
if (shouldHideFeedEvent(event)) return false;
|
||||
return authorSet.has(event.pubkey);
|
||||
});
|
||||
const { coordinates, eventIds } = extractDonationTargets(filtered);
|
||||
|
||||
// Donation enrichment: pull lightning + onchain zaps that reference
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import {
|
||||
extractOnchainZapTxid,
|
||||
verifyOnchainZap,
|
||||
} from '@/hooks/useOnchainZaps';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
|
||||
export interface ProfileCampaignStats {
|
||||
/** Total number of non-deleted campaigns authored by this pubkey. */
|
||||
campaignCount: number;
|
||||
/**
|
||||
* Sum of verified on-chain donations across all of this user's
|
||||
* campaigns, in sats. Silent-payment campaigns contribute 0 by design
|
||||
* (donations are unlinkable, no receipts are published).
|
||||
*/
|
||||
totalRaisedSats: number;
|
||||
/** True while donation verification queries are still resolving. */
|
||||
isVerifying: boolean;
|
||||
/** The raw campaigns list, for reuse by the chip click handler. */
|
||||
campaigns: ParsedCampaign[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate campaign and donation stats for a single profile.
|
||||
*
|
||||
* Mirrors {@link useCampaignDonations} per campaign — fetches kind 8333
|
||||
* receipts targeting each `a` coord, dedupes by txid, and verifies each
|
||||
* one on-chain against the campaign's `w` address before counting it
|
||||
* toward the total. Silent-payment campaigns are excluded from the
|
||||
* verification fan-out (their donations are intentionally unlinkable).
|
||||
*
|
||||
* Lazy: returns 0 / empty until the campaigns list resolves, then fans
|
||||
* out receipt fetches in parallel. Suitable for header stat chips where
|
||||
* an in-flight number is fine.
|
||||
*/
|
||||
export function useProfileCampaignStats(pubkey: string | undefined): ProfileCampaignStats {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const { esploraApis } = config;
|
||||
|
||||
const campaignsQuery = useCampaigns(
|
||||
pubkey ? { authors: [pubkey], limit: 100 } : { authors: [], limit: 0 },
|
||||
);
|
||||
const campaigns = pubkey ? (campaignsQuery.data ?? []) : [];
|
||||
|
||||
// Fan out: one receipt fetch per on-chain campaign.
|
||||
const onchainCampaigns = campaigns.filter((c) => c.wallet?.mode === 'onchain');
|
||||
const receiptsQueries = useQueries({
|
||||
queries: onchainCampaigns.map((campaign) => ({
|
||||
queryKey: ['campaign-donations', 'events', campaign.aTag],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }): Promise<NostrEvent[]> => {
|
||||
return nostr.query(
|
||||
[{ kinds: [8333], '#a': [campaign.aTag], limit: 500 }],
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
staleTime: 15_000,
|
||||
})),
|
||||
});
|
||||
|
||||
// Flatten the receipts and dedupe by txid (prefer earliest, like
|
||||
// useCampaignDonations does). Track which campaign each txid belongs to
|
||||
// so we can verify against the right wallet.
|
||||
const verificationInputs: Array<{ campaign: ParsedCampaign; event: NostrEvent }> = [];
|
||||
const seenByCampaign = new Map<string, Set<string>>();
|
||||
for (let i = 0; i < onchainCampaigns.length; i++) {
|
||||
const campaign = onchainCampaigns[i];
|
||||
const receipts = receiptsQueries[i]?.data ?? [];
|
||||
const sortedAsc = [...receipts].sort((a, b) => a.created_at - b.created_at);
|
||||
const seenTxids = new Set<string>();
|
||||
for (const event of sortedAsc) {
|
||||
const txid = extractOnchainZapTxid(event);
|
||||
if (!txid) continue;
|
||||
if (seenTxids.has(txid)) continue;
|
||||
seenTxids.add(txid);
|
||||
verificationInputs.push({ campaign, event });
|
||||
}
|
||||
seenByCampaign.set(campaign.aTag, seenTxids);
|
||||
}
|
||||
|
||||
const verifications = useQueries({
|
||||
queries: verificationInputs.map(({ campaign, event }) => ({
|
||||
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, campaign.wallet?.value ?? ''],
|
||||
queryFn: () => verifyOnchainZap(event, esploraApis, campaign.wallet?.value),
|
||||
staleTime: 60_000,
|
||||
enabled: !!campaign.wallet?.value,
|
||||
})),
|
||||
});
|
||||
|
||||
const totalRaisedSats = verifications.reduce(
|
||||
(sum, v) => sum + (v.data?.amountSats ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const isVerifying =
|
||||
campaignsQuery.isLoading ||
|
||||
receiptsQueries.some((q) => q.isLoading) ||
|
||||
verifications.some((v) => v.isLoading);
|
||||
|
||||
return {
|
||||
campaignCount: campaigns.length,
|
||||
totalRaisedSats,
|
||||
isVerifying,
|
||||
campaigns,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
COMMUNITY_DEFINITION_KIND,
|
||||
parseCommunityEvent,
|
||||
type ParsedCommunity,
|
||||
} from '@/lib/communityUtils';
|
||||
import { dedupeAddressableLatest } from '@/lib/addressableEvents';
|
||||
|
||||
export interface ProfileOrganization {
|
||||
community: ParsedCommunity;
|
||||
event: NostrEvent;
|
||||
/** This pubkey is the founder (author of the kind 34550 event). */
|
||||
isFounder: boolean;
|
||||
/** This pubkey is listed in a `p` tag with role `moderator`. */
|
||||
isModerator: boolean;
|
||||
}
|
||||
|
||||
function organizationRank(entry: ProfileOrganization): number {
|
||||
if (entry.isFounder) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Organizations any given pubkey is publicly associated with — orgs they
|
||||
* founded and orgs they moderate.
|
||||
*
|
||||
* Distinct from {@link useUserOrganizations}, which augments the logged-in
|
||||
* user's view with their private NIP-51 community bookmarks (kind 10004).
|
||||
* Bookmarks are personal state that a third party can't see without the
|
||||
* owner's keys, so for someone else's profile we surface only the public
|
||||
* founder + moderator signals.
|
||||
*/
|
||||
export function useProfileOrganizations(pubkey: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const query = useQuery<ProfileOrganization[]>({
|
||||
queryKey: ['profile-organizations', pubkey ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!pubkey) return [];
|
||||
|
||||
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
|
||||
const [foundedEvents, pTaggedEvents] = await Promise.all([
|
||||
nostr.query(
|
||||
[{ kinds: [COMMUNITY_DEFINITION_KIND], authors: [pubkey], limit: 50 }],
|
||||
{ signal: combinedSignal },
|
||||
),
|
||||
nostr.query(
|
||||
[{ kinds: [COMMUNITY_DEFINITION_KIND], '#p': [pubkey], limit: 100 }],
|
||||
{ signal: combinedSignal },
|
||||
),
|
||||
]);
|
||||
|
||||
const entries: ProfileOrganization[] = [];
|
||||
for (const event of dedupeAddressableLatest([...foundedEvents, ...pTaggedEvents])) {
|
||||
const community = parseCommunityEvent(event);
|
||||
if (!community) continue;
|
||||
|
||||
const isFounder = community.founderPubkey === pubkey;
|
||||
const isModerator = community.moderatorPubkeys.includes(pubkey);
|
||||
|
||||
if (!isFounder && !isModerator) continue;
|
||||
|
||||
entries.push({ community, event, isFounder, isModerator });
|
||||
}
|
||||
|
||||
entries.sort((a, b) => {
|
||||
const rankDiff = organizationRank(a) - organizationRank(b);
|
||||
if (rankDiff !== 0) return rankDiff;
|
||||
return b.event.created_at - a.event.created_at;
|
||||
});
|
||||
|
||||
return entries;
|
||||
},
|
||||
enabled: !!pubkey,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
data: query.data ?? [],
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
}),
|
||||
[query.data, query.isLoading, query.isError, query.error],
|
||||
);
|
||||
}
|
||||
+31
-36
@@ -704,7 +704,8 @@
|
||||
}
|
||||
|
||||
/* Slow Ken-Burns-style pan for full-bleed hero images. The image is
|
||||
oversized (scale 1.12) so the translation never reveals the edge. */
|
||||
oversized (scale 1.12) so the translation never reveals the edge.
|
||||
Used by HeroBanner on Communities/Actions/Guide pages. */
|
||||
@keyframes heroPanRight {
|
||||
0% { transform: scale(1.12) translateX(-1.5%); }
|
||||
50% { transform: scale(1.12) translateX(1.5%); }
|
||||
@@ -718,43 +719,37 @@
|
||||
.hero-pan-right { animation: heroPanRight 28s ease-in-out infinite; }
|
||||
.hero-pan-left { animation: heroPanLeft 28s ease-in-out infinite; }
|
||||
|
||||
/* Slow breathing glow used on the hero globe's outer halo so the beacon
|
||||
feels alive rather than statically pasted. The radius itself doesn't
|
||||
change — only opacity — so the layout stays rock-stable. Range is
|
||||
intentionally narrow + low so the halo is felt, not seen. */
|
||||
@keyframes heroGlobeHaloBreath {
|
||||
0% { opacity: 0.32; }
|
||||
50% { opacity: 0.55; }
|
||||
100% { opacity: 0.32; }
|
||||
/* Lightning-arc flow on the home hero. Each path is dashed and the
|
||||
dash offset animates by exactly one full dash period, so the loop
|
||||
point is visually identical to the start point — no snap, no
|
||||
visible restart. Dash sizes are in screen pixels because the arcs
|
||||
use `vector-effect: non-scaling-stroke`. */
|
||||
@keyframes heroArcFlow {
|
||||
0% { stroke-dashoffset: 0; }
|
||||
100% { stroke-dashoffset: -16; }
|
||||
}
|
||||
.hero-arc-flow {
|
||||
stroke-dasharray: 6 10;
|
||||
animation: heroArcFlow 2.4s linear infinite;
|
||||
}
|
||||
|
||||
/* City-node pulse used on the home hero. The dot itself doesn't move;
|
||||
we just breathe the halo's opacity so the location reads as a live
|
||||
beacon. Offsets are staggered per-node from the JSX so the field
|
||||
pulses organically rather than in lockstep. */
|
||||
@keyframes heroNodePulse {
|
||||
0% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 0.95; transform: scale(1.15); }
|
||||
100% { opacity: 0.35; transform: scale(0.85); }
|
||||
}
|
||||
.hero-node-pulse {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
animation: heroNodePulse 3.2s ease-in-out infinite;
|
||||
}
|
||||
.hero-globe-halo-breath { animation: heroGlobeHaloBreath 6.5s ease-in-out infinite; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-pan-right, .hero-pan-left { animation: none; }
|
||||
.hero-globe-halo-breath { animation: none; opacity: 0.42; }
|
||||
}
|
||||
|
||||
/* Dark drop-shadow for hero copy — photo backgrounds are always colorful,
|
||||
so we want a dark shadow regardless of light/dark theme. */
|
||||
.hero-text-shadow {
|
||||
text-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.8),
|
||||
0 0 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.hero-text-shadow-soft {
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
/* On mobile the globe sits under the headline — soften its left edge. */
|
||||
.hero-globe-mask {
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 48%, black 100%);
|
||||
mask-image: linear-gradient(to right, transparent 0%, black 48%, black 100%);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.hero-globe-mask {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
.hero-arc-flow { animation: none; stroke-dasharray: none; }
|
||||
.hero-node-pulse { animation: none; opacity: 0.7; }
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import './index.css';
|
||||
import './i18n';
|
||||
|
||||
import '@fontsource-variable/inter';
|
||||
import '@fontsource/bebas-neue/400.css';
|
||||
|
||||
// ─── Native status bar theming (Android APK / iOS) ───────────────────────────
|
||||
// Keeps the OS top chrome in sync with the active app theme.
|
||||
|
||||
@@ -184,12 +184,12 @@ function ActionCard({ action, isExpired, btcPrice }: { action: Action; isExpired
|
||||
to={`/${naddr}`}
|
||||
className="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"
|
||||
>
|
||||
<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', isExpired && 'opacity-75')}>
|
||||
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
|
||||
<div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
|
||||
<img
|
||||
src={coverImage}
|
||||
alt=""
|
||||
className={cn('absolute inset-0 size-full object-cover', isExpired && 'grayscale')}
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
onError={() => setImageLoadFailed(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
+57
-196
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ChevronDown, EyeOff, HandHeart, Hourglass, PlusCircle, ShieldCheck } from 'lucide-react';
|
||||
@@ -11,11 +11,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
import { HeroGlobe } from '@/components/HeroGlobe';
|
||||
import { HeroCampaignSpotlight } from '@/components/HeroCampaignSpotlight';
|
||||
import { CampaignHeroBackground } from '@/components/CampaignHeroBackground';
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { hopeHueFor } from '@/lib/hopePalette';
|
||||
import { HeroLightningMap } from '@/components/HeroLightningMap';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
@@ -25,8 +21,6 @@ import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { type ParsedCampaign } from '@/lib/campaign';
|
||||
|
||||
import { getCoordinates } from '@/lib/coordinates';
|
||||
|
||||
/** Cap on how many featured campaigns we render in the home-page row. */
|
||||
const MAX_FEATURED = 4;
|
||||
|
||||
@@ -150,194 +144,70 @@ export function CampaignsPage() {
|
||||
);
|
||||
}, [isMod, user, moderation, ownCampaigns]);
|
||||
|
||||
// Build the spotlight pool from the featured campaigns only. A featured
|
||||
// campaign without a resolvable country is silently dropped — the globe
|
||||
// needs coordinates to pin it, and the banner cycles in lockstep with
|
||||
// the globe so the two stay in sync.
|
||||
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;
|
||||
const countryCode = c.countryCode;
|
||||
if (!countryCode) return;
|
||||
const coords = getCoordinates(countryCode);
|
||||
if (!coords) return;
|
||||
// Deduplicate by country so two featured campaigns in the same
|
||||
// country don't pile overlapping markers on the globe. Featured
|
||||
// order is preserved — the first one wins.
|
||||
if (seenCountry.has(countryCode)) return;
|
||||
seenAtag.add(c.aTag);
|
||||
seenCountry.add(countryCode);
|
||||
out.push({ key: c.aTag, campaign: c, lat: coords.latitude, lng: coords.longitude });
|
||||
};
|
||||
|
||||
for (const c of orderedFeatured) {
|
||||
add(c);
|
||||
}
|
||||
return out;
|
||||
}, [orderedFeatured]);
|
||||
|
||||
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]);
|
||||
|
||||
// Active "hopeful" hue keyed to the spotlit campaign. Both the
|
||||
// surrounding atmosphere and the globe consume this so the entire
|
||||
// hero shifts color together as campaigns cycle.
|
||||
const activeHue = useMemo(
|
||||
() => hopeHueFor(spotlightCampaign?.aTag),
|
||||
[spotlightCampaign?.aTag],
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16">
|
||||
{/* 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.
|
||||
Dark, brand-driven, type-led. Three layers:
|
||||
1. Near-black backdrop (`bg-[hsl(220_25%_6%)]`) — the canvas
|
||||
every other element sits on. No campaign photo, no random
|
||||
hue cycling: the hero looks the same on every visit, so
|
||||
quality doesn't depend on which campaign is featured.
|
||||
2. HeroLightningMap — decorative dark world map with curated
|
||||
glowing brand-orange arcs and pulsing city nodes. Pure SVG,
|
||||
negligible render cost, animations honor reduced-motion.
|
||||
3. Headline column on the left, lifted by a left-edge gradient
|
||||
inside HeroLightningMap so type stays readable without any
|
||||
text-shadow at all. */}
|
||||
<section className="relative overflow-hidden border-b border-border bg-[hsl(220_25%_6%)] text-white">
|
||||
<HeroLightningMap />
|
||||
|
||||
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?.banner} />
|
||||
|
||||
{/* Per-campaign hopeful color atmosphere. Sits on top of the
|
||||
photo and below the globe so its mix-blend-mode: screen layers
|
||||
can warm the darks back up — the photo ends up tinted toward
|
||||
the active hue without losing headline contrast. */}
|
||||
<HeroAtmosphere seed={spotlightCampaign?.aTag} />
|
||||
|
||||
{/* Globe — center sits a little to the left of the TopNav account
|
||||
switcher anchor so a larger slice of the sphere reads inside
|
||||
the hero rather than off the right edge. */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="relative max-w-7xl mx-auto h-full px-4 sm:px-6">
|
||||
<div className="absolute inset-y-0 right-4 sm:right-6 flex items-center">
|
||||
<div className="pointer-events-auto hero-globe-mask translate-x-[40%] sm:translate-x-[38%] lg:translate-x-[38%] opacity-90">
|
||||
<HeroGlobe
|
||||
markers={globeMarkers}
|
||||
selectedKey={highlightedMarkerKey}
|
||||
onMarkerClick={(key) =>
|
||||
// Toggle off when the user re-clicks the active
|
||||
// marker, restoring the auto-cycle.
|
||||
setSelectedKey((prev) => (prev === key ? null : key))
|
||||
}
|
||||
hue={activeHue}
|
||||
// Fluid sizing scaled to dynamic viewport width (dvw),
|
||||
// clamped so it never shrinks below phone-comfortable
|
||||
// nor balloons on ultra-wide monitors.
|
||||
className="aspect-square max-w-none drop-shadow-2xl"
|
||||
style={{ width: 'clamp(620px, 58dvw, 820px)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Readability scrim. Layered *above* the globe so the headline
|
||||
area stays legible even when a slice of the sphere sits behind
|
||||
it. Only shown on tablet and below — at lg+ the globe sits
|
||||
outside the headline column, so the scrim would just mute the
|
||||
photo for no benefit. */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none bg-gradient-to-r from-black/55 via-black/30 to-transparent lg:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Foreground content — headline + CTAs at the top, spotlight info
|
||||
at the bottom. Shares the `max-w-7xl mx-auto` container with the
|
||||
globe so everything aligns to the same left/right axis. */}
|
||||
<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 text-white">
|
||||
<div className="relative space-y-5 max-w-2xl">
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.1] hero-text-shadow">
|
||||
Connecting activists to unstoppable funding.
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-16 lg:py-24 min-h-[440px] sm:min-h-[480px] lg:min-h-[520px] flex flex-col justify-center">
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<h1
|
||||
className="font-display italic text-6xl sm:text-7xl lg:text-8xl font-normal tracking-wide leading-none uppercase"
|
||||
style={{
|
||||
// Bebas Neue only ships at weight 400. Paint a stroke the
|
||||
// same color as the fill to fatten the letterforms without
|
||||
// the fuzz a synthetic-bold transform would produce.
|
||||
WebkitTextStroke: '0.022em currentColor',
|
||||
}}
|
||||
>
|
||||
Connecting activists to
|
||||
{/* "unstoppable" gets a solid brand-orange highlighter
|
||||
block on its own line. The negative left margin
|
||||
(`-ml-1.5`) pulls the box's left edge back by exactly
|
||||
the box's own horizontal padding so the U sits flush
|
||||
with the column's left edge instead of being inset by
|
||||
the highlighter's padding. */}
|
||||
<br />
|
||||
{/* Asymmetric padding: zero on the left so "unstoppable"'s
|
||||
U sits flush with the column edge (matching the row
|
||||
above), but extra padding on the right so the orange
|
||||
box extends past the word's trailing edge as a
|
||||
deliberate visual flourish. The inner text is then
|
||||
nudged slightly leftward (negative left margin on the
|
||||
inner element) so the U optically aligns with the
|
||||
"C" in "Connecting" — Bebas Neue's italic skew shifts
|
||||
the visual left edge of the U rightward of its
|
||||
geometric box. */}
|
||||
<span className="inline-block w-fit pl-0 pr-3 pt-1 pb-0 -mt-1 -mb-3 bg-primary text-white leading-[0.8] align-baseline">
|
||||
<span className="-ml-1 inline-block">unstoppable</span>
|
||||
</span>{' '}
|
||||
funding.
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-white/85 max-w-2xl hero-text-shadow-soft">
|
||||
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 className="text-base sm:text-lg text-white/80 max-w-xl">
|
||||
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">
|
||||
{/* Primary CTA — clean translucent glass pill with a hint
|
||||
of warmth bleeding through. The hopefulness comes from
|
||||
the photo + atmosphere underneath plus a soft warm
|
||||
shadow, not from added gloss. */}
|
||||
<div className="flex flex-wrap gap-3 pt-1">
|
||||
{/* Primary CTA — solid brand-orange pill. The dark hero gives
|
||||
the brand color the spotlight without competing with it. */}
|
||||
<Button
|
||||
size="lg"
|
||||
asChild
|
||||
className={cn(
|
||||
// A touch larger than the default lg button — enough
|
||||
// weight to read as primary, not enough to feel chunky.
|
||||
'relative rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px]',
|
||||
// Subtle warm-tinted glass body, kept more transparent.
|
||||
// Hover lifts the tint a hair without changing the pill
|
||||
// character — no shadow bloom, no halo.
|
||||
'bg-gradient-to-br from-white/14 via-amber-100/10 to-rose-100/10 hover:from-white/20 hover:via-amber-100/14 hover:to-rose-100/14',
|
||||
'backdrop-blur-xl backdrop-saturate-150',
|
||||
'border border-white/25 hover:border-white/35',
|
||||
// Single hair-thin inner highlight + a warm-tinted
|
||||
// realistic drop shadow that ties the button to the
|
||||
// hopeful palette. Hover only nudges the shadow.
|
||||
'shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08),0_10px_28px_-12px_hsl(24_85%_45%/0.4)]',
|
||||
'hover:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12),0_12px_32px_-10px_hsl(24_85%_45%/0.5)]',
|
||||
'motion-safe:transition-colors motion-safe:duration-200',
|
||||
)}
|
||||
className="rounded-full text-primary-foreground font-semibold text-base h-12 px-7 [&_svg]:size-[18px] motion-safe:transition-colors"
|
||||
>
|
||||
<Link to="/campaigns/new">
|
||||
<PlusCircle className="mr-2" />
|
||||
@@ -349,22 +219,13 @@ export function CampaignsPage() {
|
||||
variant="outline"
|
||||
size="lg"
|
||||
asChild
|
||||
className="rounded-full bg-background/70 backdrop-blur h-12 px-6 text-base text-foreground"
|
||||
className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50"
|
||||
>
|
||||
<a href="#campaigns">Explore campaigns</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(spotlightCampaign || (featuredLoading && spotlightables.length === 0)) && (
|
||||
<div className="relative mt-auto pt-10 max-w-sm">
|
||||
<HeroCampaignSpotlight
|
||||
campaign={spotlightCampaign}
|
||||
isLoading={featuredLoading && spotlightables.length === 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
+181
-1425
File diff suppressed because it is too large
Load Diff
@@ -714,6 +714,7 @@ export function ProfileSettings() {
|
||||
onChange={handleCardChange}
|
||||
onPickImage={handlePickImage}
|
||||
onRemoveAvatar={() => form.setValue('picture', '', { shouldDirty: true })}
|
||||
showBadges={false}
|
||||
/>
|
||||
|
||||
{isUploading && (
|
||||
|
||||
@@ -32,6 +32,7 @@ export default {
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
|
||||
display: ['Bebas Neue', 'Impact', 'Inter Variable', 'system-ui', 'sans-serif'],
|
||||
emoji: ['Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', 'Twemoji Mozilla', 'Android Emoji', 'EmojiSymbols', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
|
||||
Reference in New Issue
Block a user