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