Showcase all Venezuela relief campaigns instead of one
Replace the single baked-in terremoto-venezuela campaign with a live, agnostic showcase: every Venezuela-located campaign tagged `humanitarian-aid` or `emergency-relief` and created since the earthquake is pulled in and rendered as a card carousel across the home hero and the dedicated relief page. - New useVenezuelaReliefCampaigns hook queries kind 33863 filtered by `#i: [iso3166:VE]` + `#t: [humanitarian-aid, emergency-relief]` + `since` (quake timestamp), and aggregates on-chain raised totals the same way useProfileCampaignStats does (one /address lookup per campaign, summed; no per-receipt /tx fan-out). The flagship terremoto-venezuela campaign predates the geo-tag convention and carries no `iso3166:VE` tag, so it's pinned by coordinate and merged ahead of the filtered results, deduped by aTag. - Extend useCampaigns with `categories` (`#t`) and `since` options. - New VenezuelaReliefShowcase: an auto-panning marquee of CampaignCards (seamless -50% loop, hover/focus pause, click-drag + momentum, edge fades, prefers-reduced-motion fallback to a native scroll rail), ported from the surveil deck shelf. - Hero/page CTAs and the popup donate button now point at the relief page (no more single-campaign naddr deep-link); the page CTA scrolls to the showcase. Drop the embedded CampaignDetailPage, the .relief-donate-flash CSS, and the now-unused scroll-to-campaign logic. - VenezuelaReliefGoal now reads the aggregate hook and shows the combined raised total + matching-campaign count only (no goal bar — an aggregate goal across independent campaigns is meaningless). - Tighten the hero height so the carousel sits near the fold. - Locale updates across all 16 languages: drop `startCampaign`/`goalOf`, add `showcaseTitle` + `campaignCount`.
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agora",
|
||||
"version": "2.8.9",
|
||||
"version": "2.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agora",
|
||||
"version": "2.8.9",
|
||||
"version": "2.9.0",
|
||||
"dependencies": {
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
|
||||
@@ -5,11 +5,11 @@ import { HeartHandshake, Share2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HeroBanner } from '@/components/HeroBanner';
|
||||
import { VenezuelaReliefGoal } from '@/components/VenezuelaReliefGoal';
|
||||
import { VenezuelaReliefShowcase } from '@/components/VenezuelaReliefShowcase';
|
||||
import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { shareOrCopy } from '@/lib/share';
|
||||
import {
|
||||
VENEZUELA_DONATE_PATH,
|
||||
VENEZUELA_RELIEF_IMAGES,
|
||||
VENEZUELA_RELIEF_PATH,
|
||||
} from '@/lib/venezuelaRelief';
|
||||
@@ -40,9 +40,13 @@ const VENEZUELA_RELIEF_BANNER_IMAGES = VENEZUELA_RELIEF_IMAGES;
|
||||
* - A large display headline ("Venezuela needs you") with the final
|
||||
* word painted inside a solid brand-orange highlighter block — the
|
||||
* same idiom as the home hero's "unstoppable".
|
||||
* - A primary call to action — **Donate to relief** — deep-links
|
||||
* straight to the baked-in relief campaign (its naddr) so donors land
|
||||
* on the campaign's detail page, plus a **Share** action.
|
||||
* - A primary call to action — **Donate to relief** — links to the
|
||||
* dedicated relief page ({@link VENEZUELA_RELIEF_PATH}), which showcases
|
||||
* every Venezuela campaign tagged for relief, plus a **Share** action.
|
||||
*
|
||||
* Beneath the hero sits a live showcase rail
|
||||
* ({@link VenezuelaReliefShowcase}) of those same matching campaigns, so
|
||||
* donors can pick a specific effort without leaving the home page.
|
||||
*
|
||||
* Not dismissible by design — while the appeal is active it stays put
|
||||
* for every visitor (product decision). When the response winds down,
|
||||
@@ -123,12 +127,12 @@ export function VenezuelaReliefBanner({ className }: { className?: string }) {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Layer 3 — content. Fills ~85% of the initial viewport so it
|
||||
reads as the headline of the day rather than a sibling band,
|
||||
with a sensible minimum on very short / very tall screens.
|
||||
{/* Layer 3 — content. Sized so the headline + CTAs read as the
|
||||
page's hero while still leaving the showcase rail below partly
|
||||
in view above the fold (no full-viewport gap to scroll past).
|
||||
`dvh` so mobile browser chrome (collapsing address bar) doesn't
|
||||
jump the height. */}
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-20 sm:py-28 h-[85dvh] min-h-[520px] max-h-[1200px] flex flex-col justify-center">
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-20 sm:py-28 min-h-[560px] sm:min-h-[680px] flex flex-col justify-center">
|
||||
<div className="max-w-3xl">
|
||||
<h2
|
||||
id="venezuela-relief-title"
|
||||
@@ -165,13 +169,14 @@ export function VenezuelaReliefBanner({ className }: { className?: string }) {
|
||||
<VenezuelaReliefGoal variant="overlay" className="mt-7" />
|
||||
|
||||
<div className="mt-7 flex flex-col sm:flex-row flex-wrap gap-3">
|
||||
{/* Primary CTA — donate to Venezuela-filtered relief campaigns */}
|
||||
{/* Primary CTA — the dedicated relief page showcasing every
|
||||
Venezuela campaign tagged for relief. */}
|
||||
<Button
|
||||
size="lg"
|
||||
asChild
|
||||
className="rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px] motion-safe:transition-colors"
|
||||
>
|
||||
<Link to={VENEZUELA_DONATE_PATH}>
|
||||
<Link to={VENEZUELA_RELIEF_PATH}>
|
||||
<HeartHandshake className="mr-2" />
|
||||
{t('campaigns.home.venezuelaRelief.donate')}
|
||||
</Link>
|
||||
@@ -203,6 +208,17 @@ export function VenezuelaReliefBanner({ className }: { className?: string }) {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Showcase rail — every matching relief campaign, pulled in live so
|
||||
donors can pick a specific effort straight from the home page.
|
||||
Sits on a translucent panel anchored to the bottom of the hero so
|
||||
it reads as part of the appeal, over the photo gallery. Renders
|
||||
nothing until campaigns resolve. */}
|
||||
<div className="relative border-t border-white/10 bg-black/40 backdrop-blur-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||
<VenezuelaReliefShowcase variant="overlay" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,103 +1,87 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatCampaignAmount, formatUsdGoal } from '@/lib/formatCampaignAmount';
|
||||
import { useVenezuelaReliefCampaign } from '@/hooks/useVenezuelaReliefCampaign';
|
||||
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
|
||||
import { useVenezuelaReliefCampaigns } from '@/hooks/useVenezuelaReliefCampaigns';
|
||||
|
||||
interface VenezuelaReliefGoalProps {
|
||||
/**
|
||||
* `overlay` — light text + translucent track, for the dark hero photo
|
||||
* backgrounds (banner + page). `card` — foreground text + muted track,
|
||||
* for the popup's light card surface.
|
||||
* `overlay` — light text, for the dark hero photo backgrounds (banner +
|
||||
* page). `card` — foreground text, for the popup's light card surface.
|
||||
*/
|
||||
variant?: 'overlay' | 'card';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live fundraising readout for the baked-in Venezuela relief campaign —
|
||||
* the raised total, goal, donation count, and a progress bar. Shared by
|
||||
* the home hero ({@link VenezuelaReliefBanner}), the session popup
|
||||
* Aggregate fundraising readout for the Venezuela relief showcase — the
|
||||
* combined raised total across every matching campaign, plus the
|
||||
* matching-campaign count. Shared by the home hero
|
||||
* ({@link VenezuelaReliefBanner}), the session popup
|
||||
* ({@link VenezuelaReliefPopup}), and the dedicated page
|
||||
* ({@link VenezuelaReliefPage}) so each surface is an info + donation
|
||||
* hybrid backed by the same numbers as the campaign detail page.
|
||||
* hybrid backed by the same numbers.
|
||||
*
|
||||
* Renders nothing once loaded if the campaign can't be resolved or has no
|
||||
* goal/raised data — the surrounding appeal copy and CTAs stand on their
|
||||
* No goal or progress bar: the appeal spans many independent campaigns
|
||||
* (each with its own goal, shown on its card), so an aggregate "goal" is
|
||||
* meaningless here — we surface the combined raised total only.
|
||||
*
|
||||
* Renders nothing once loaded if no matching campaigns resolve, or they've
|
||||
* raised nothing yet — the surrounding appeal copy and CTAs stand on their
|
||||
* own, so this never leaves an empty box.
|
||||
*/
|
||||
export function VenezuelaReliefGoal({ variant = 'overlay', className }: VenezuelaReliefGoalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isLoading, raisedSats, goalUsd, donationCount, percent, btcPrice } =
|
||||
useVenezuelaReliefCampaign();
|
||||
const { isLoading, raisedSats, campaignCount, btcPrice } =
|
||||
useVenezuelaReliefCampaigns();
|
||||
|
||||
const isOverlay = variant === 'overlay';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('w-full max-w-md space-y-2', className)}>
|
||||
<Skeleton className={cn('h-6 w-40', isOverlay && 'bg-white/20')} />
|
||||
<Skeleton className={cn('h-2 w-full', isOverlay && 'bg-white/20')} />
|
||||
<Skeleton className={cn('h-7 w-44', isOverlay && 'bg-white/20')} />
|
||||
<Skeleton className={cn('h-3 w-28', isOverlay && 'bg-white/20')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing meaningful to show — let the appeal copy carry the surface.
|
||||
if (raisedSats <= 0 && !goalUsd) return null;
|
||||
if (raisedSats <= 0) return null;
|
||||
|
||||
const raisedLabel = formatCampaignAmount(raisedSats, btcPrice);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full max-w-md space-y-2',
|
||||
'w-full max-w-md space-y-1',
|
||||
isOverlay
|
||||
? 'drop-shadow-[0_1px_8px_rgba(0,0,0,0.6)]'
|
||||
: 'rounded-lg border border-border bg-muted/40 p-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'text-2xl sm:text-3xl font-bold tracking-tight',
|
||||
isOverlay ? 'text-white' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{raisedLabel}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xl sm:text-2xl font-bold tracking-tight',
|
||||
isOverlay ? 'text-white' : 'text-foreground',
|
||||
'ml-1.5 text-sm font-normal',
|
||||
isOverlay ? 'text-white/70' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{raisedLabel}
|
||||
<span
|
||||
className={cn(
|
||||
'ml-1.5 text-sm font-normal',
|
||||
isOverlay ? 'text-white/70' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{t('campaignsDetail.raised')}
|
||||
</span>
|
||||
{t('campaignsDetail.raised')}
|
||||
</span>
|
||||
{goalUsd ? (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 text-xs sm:text-sm',
|
||||
isOverlay ? 'text-white/70' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{t('campaigns.home.venezuelaRelief.goalOf', { amount: formatUsdGoal(goalUsd) })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
{percent !== undefined && (
|
||||
<Progress
|
||||
value={percent}
|
||||
className={cn('h-2', isOverlay ? 'bg-white/25' : 'bg-foreground/15')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{donationCount > 0 && (
|
||||
{campaignCount > 0 && (
|
||||
<p className={cn('text-xs', isOverlay ? 'text-white/70' : 'text-muted-foreground')}>
|
||||
{t('campaignsDetail.donationCount', { count: donationCount })}
|
||||
{t('campaigns.home.venezuelaRelief.campaignCount', { count: campaignCount })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { shareOrCopy } from '@/lib/share';
|
||||
import {
|
||||
VENEZUELA_DONATE_PATH,
|
||||
VENEZUELA_RELIEF_IMAGES,
|
||||
VENEZUELA_RELIEF_PATH,
|
||||
VENEZUELA_RELIEF_POPUP_SEEN_KEY,
|
||||
@@ -129,7 +128,7 @@ export function VenezuelaReliefPopup() {
|
||||
|
||||
<DialogFooter className="mt-5 sm:flex-row sm:justify-start sm:gap-2">
|
||||
<Button asChild className="rounded-full font-semibold [&_svg]:size-[18px]">
|
||||
<Link to={VENEZUELA_DONATE_PATH} onClick={() => setOpen(false)}>
|
||||
<Link to={VENEZUELA_RELIEF_PATH} onClick={() => setOpen(false)}>
|
||||
<HeartHandshake className="mr-2" />
|
||||
{t('campaigns.home.venezuelaRelief.donate')}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
import { useVenezuelaReliefCampaigns } from '@/hooks/useVenezuelaReliefCampaigns';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface VenezuelaReliefShowcaseProps {
|
||||
/**
|
||||
* `overlay` — heading in white + edge fades to the dark hero background,
|
||||
* for the banner / page hero. `default` — foreground heading + edge fades
|
||||
* to `bg-background`, for a light section surface.
|
||||
*/
|
||||
variant?: 'overlay' | 'default';
|
||||
className?: string;
|
||||
/** Optional id for scroll-into-view targeting (page "Donate" CTA). */
|
||||
id?: string;
|
||||
/**
|
||||
* Pixels-per-second pan speed for the auto-scroll marquee. Keep it slow —
|
||||
* the rail should feel ambient, not demanding. Defaults to 24 px/s.
|
||||
*/
|
||||
pxPerSecond?: number;
|
||||
}
|
||||
|
||||
const CARD_WIDTH_CLASS = 'w-[300px]';
|
||||
|
||||
/**
|
||||
* Horizontal auto-scrolling "marquee" of every Venezuela-located campaign
|
||||
* tagged for relief (`humanitarian-aid` / `emergency-relief`) created since
|
||||
* the earthquake, resolved live via {@link useVenezuelaReliefCampaigns}.
|
||||
* Shared by the home hero ({@link VenezuelaReliefBanner}) and the dedicated
|
||||
* page ({@link VenezuelaReliefPage}).
|
||||
*
|
||||
* Interaction model (ported from the surveil deck shelf):
|
||||
*
|
||||
* - Pans on its own at `pxPerSecond`; the campaign list is duplicated in
|
||||
* the DOM so the track wraps at -50% with no visible jump.
|
||||
* - Hover / focus pauses the pan so clicking a card isn't a moving target.
|
||||
* - Click-drag / swipe scrubs the rail, with a short momentum coast on
|
||||
* release; a travel threshold suppresses the click so a drag never
|
||||
* accidentally navigates into a card.
|
||||
* - Honors `prefers-reduced-motion`: the track sits still and becomes a
|
||||
* native horizontal scroll container so content stays reachable.
|
||||
* - Soft gradient fades on both edges so cards dissolve in and out rather
|
||||
* than hitting a hard cutoff.
|
||||
*
|
||||
* Renders nothing once loaded if no campaigns match, so the surrounding
|
||||
* appeal copy and CTAs carry the surface alone.
|
||||
*/
|
||||
export function VenezuelaReliefShowcase({
|
||||
variant = 'default',
|
||||
className,
|
||||
id,
|
||||
pxPerSecond = 24,
|
||||
}: VenezuelaReliefShowcaseProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isLoading, campaigns } = useVenezuelaReliefCampaigns();
|
||||
|
||||
const isOverlay = variant === 'overlay';
|
||||
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Pan offset and pause flag live in refs so hover (or any pause flip)
|
||||
// doesn't restart the rAF effect and reset motion to 0.
|
||||
const offsetRef = useRef(0);
|
||||
const pausedRef = useRef(false);
|
||||
|
||||
// Drag / swipe state — all refs so gestures never trigger re-renders.
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragStartXRef = useRef(0);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
const dragTravelRef = useRef(0);
|
||||
const pointerIdRef = useRef<number | null>(null);
|
||||
const lastDragXRef = useRef(0);
|
||||
const lastDragTimeRef = useRef(0);
|
||||
const velocityRef = useRef(0);
|
||||
const momentumRafRef = useRef(0);
|
||||
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const update = () => setReduceMotion(mq.matches);
|
||||
update();
|
||||
mq.addEventListener('change', update);
|
||||
return () => mq.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
// Clamp offset within one full copy of the pool (seamless-loop invariant).
|
||||
const clampOffset = useCallback((raw: number): number => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return raw;
|
||||
const half = el.scrollWidth / 2;
|
||||
if (half <= 0) return raw;
|
||||
let clamped = raw;
|
||||
if (-clamped >= half) clamped += half;
|
||||
if (clamped > 0) clamped -= half;
|
||||
return clamped;
|
||||
}, []);
|
||||
|
||||
// rAF loop: pan leftward, wrap at -50%. Writes transform directly to the
|
||||
// DOM so motion stays smooth across surrounding re-renders.
|
||||
useEffect(() => {
|
||||
if (reduceMotion) return;
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
if (campaigns.length === 0) return;
|
||||
|
||||
let last = performance.now();
|
||||
let raf = 0;
|
||||
const step = (now: number) => {
|
||||
const dt = (now - last) / 1000;
|
||||
last = now;
|
||||
if (!pausedRef.current) {
|
||||
offsetRef.current -= pxPerSecond * dt;
|
||||
const half = el.scrollWidth / 2;
|
||||
if (half > 0 && -offsetRef.current >= half) {
|
||||
offsetRef.current += half;
|
||||
}
|
||||
el.style.transform = `translate3d(${offsetRef.current}px, 0, 0)`;
|
||||
}
|
||||
raf = requestAnimationFrame(step);
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [pxPerSecond, campaigns.length, reduceMotion]);
|
||||
|
||||
// ── Drag / swipe handlers ──────────────────────────────────────────────
|
||||
const onDragStart = useCallback(
|
||||
(clientX: number, pointerId: number | null) => {
|
||||
if (reduceMotion) return;
|
||||
cancelAnimationFrame(momentumRafRef.current);
|
||||
isDraggingRef.current = true;
|
||||
pausedRef.current = true;
|
||||
dragStartXRef.current = clientX;
|
||||
dragStartOffsetRef.current = offsetRef.current;
|
||||
dragTravelRef.current = 0;
|
||||
lastDragXRef.current = clientX;
|
||||
lastDragTimeRef.current = performance.now();
|
||||
velocityRef.current = 0;
|
||||
// Capture lazily in onDragMove once a real drag is confirmed —
|
||||
// capturing on pointerdown re-targets the click and breaks the
|
||||
// child <Link> navigation.
|
||||
pointerIdRef.current = pointerId;
|
||||
},
|
||||
[reduceMotion],
|
||||
);
|
||||
|
||||
const onDragMove = useCallback(
|
||||
(clientX: number) => {
|
||||
if (!isDraggingRef.current) return;
|
||||
if (
|
||||
pointerIdRef.current !== null &&
|
||||
viewportRef.current &&
|
||||
dragTravelRef.current <= 4 &&
|
||||
Math.abs(clientX - dragStartXRef.current) > 4
|
||||
) {
|
||||
viewportRef.current.setPointerCapture(pointerIdRef.current);
|
||||
}
|
||||
const delta = clientX - dragStartXRef.current;
|
||||
offsetRef.current = clampOffset(dragStartOffsetRef.current + delta);
|
||||
if (trackRef.current) {
|
||||
trackRef.current.style.transform = `translate3d(${offsetRef.current}px, 0, 0)`;
|
||||
}
|
||||
dragTravelRef.current += Math.abs(clientX - lastDragXRef.current);
|
||||
const now = performance.now();
|
||||
const dt = now - lastDragTimeRef.current;
|
||||
if (dt > 0) {
|
||||
velocityRef.current = ((clientX - lastDragXRef.current) / dt) * 1000;
|
||||
}
|
||||
lastDragXRef.current = clientX;
|
||||
lastDragTimeRef.current = now;
|
||||
},
|
||||
[clampOffset],
|
||||
);
|
||||
|
||||
const onDragEnd = useCallback(() => {
|
||||
if (!isDraggingRef.current) return;
|
||||
isDraggingRef.current = false;
|
||||
|
||||
// Reset travel next frame so the click guard works for this drag but
|
||||
// doesn't bleed into future taps.
|
||||
requestAnimationFrame(() => {
|
||||
dragTravelRef.current = 0;
|
||||
});
|
||||
|
||||
let v = velocityRef.current;
|
||||
const FRICTION = 0.92;
|
||||
const coast = () => {
|
||||
v *= FRICTION;
|
||||
if (Math.abs(v) < 1) {
|
||||
pausedRef.current = false;
|
||||
return;
|
||||
}
|
||||
offsetRef.current = clampOffset(offsetRef.current + v / 60);
|
||||
if (trackRef.current) {
|
||||
trackRef.current.style.transform = `translate3d(${offsetRef.current}px, 0, 0)`;
|
||||
}
|
||||
momentumRafRef.current = requestAnimationFrame(coast);
|
||||
};
|
||||
momentumRafRef.current = requestAnimationFrame(coast);
|
||||
}, [clampOffset]);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
||||
onDragStart(e.clientX, e.pointerId);
|
||||
},
|
||||
[onDragStart],
|
||||
);
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => onDragMove(e.clientX),
|
||||
[onDragMove],
|
||||
);
|
||||
const handlePointerUp = useCallback(() => onDragEnd(), [onDragEnd]);
|
||||
const handlePointerCancel = useCallback(() => {
|
||||
isDraggingRef.current = false;
|
||||
pausedRef.current = false;
|
||||
cancelAnimationFrame(momentumRafRef.current);
|
||||
}, []);
|
||||
|
||||
// Suppress the click that fires at the end of a drag that travelled more
|
||||
// than a few pixels, so swiping never navigates into a card.
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (dragTravelRef.current > 4) e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// Once loaded with no matches, render nothing — the appeal copy stands
|
||||
// on its own rather than leaving an empty "Relief campaigns" header.
|
||||
if (!isLoading && campaigns.length === 0) return null;
|
||||
|
||||
const doubledPool: ParsedCampaign[] =
|
||||
campaigns.length > 0 ? [...campaigns, ...campaigns] : campaigns;
|
||||
|
||||
const fadeFrom = isOverlay ? 'from-black/60' : 'from-background';
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
aria-label={t('campaigns.home.venezuelaRelief.showcaseTitle')}
|
||||
className={cn('scroll-mt-20 space-y-4', className)}
|
||||
>
|
||||
<h2
|
||||
className={cn(
|
||||
'text-lg sm:text-xl font-bold tracking-tight',
|
||||
isOverlay
|
||||
? 'text-white drop-shadow-[0_1px_8px_rgba(0,0,0,0.6)]'
|
||||
: 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{t('campaigns.home.venezuelaRelief.showcaseTitle')}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className={cn(
|
||||
'relative -mx-4 sm:mx-0',
|
||||
reduceMotion
|
||||
? 'overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
: 'overflow-hidden',
|
||||
!reduceMotion && 'cursor-grab active:cursor-grabbing',
|
||||
'select-none',
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!isDraggingRef.current) pausedRef.current = true;
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!isDraggingRef.current) pausedRef.current = false;
|
||||
}}
|
||||
onFocusCapture={() => {
|
||||
pausedRef.current = true;
|
||||
}}
|
||||
onBlurCapture={() => {
|
||||
if (!isDraggingRef.current) pausedRef.current = false;
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onClick={handleClick}
|
||||
style={reduceMotion ? undefined : { touchAction: 'pan-y' }}
|
||||
>
|
||||
{/* Edge fades — absolutely-positioned gradient panels (a CSS mask
|
||||
gets bypassed by descendants with their own stacking context,
|
||||
e.g. the cards' hover transform). pointer-events-none so they
|
||||
don't swallow card clicks. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 left-0 w-16 sm:w-20 z-20 bg-gradient-to-r to-transparent',
|
||||
fadeFrom,
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 right-0 w-16 sm:w-20 z-20 bg-gradient-to-l to-transparent',
|
||||
fadeFrom,
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={cn(
|
||||
'flex items-stretch gap-4 px-4 sm:px-6 pb-2 w-max',
|
||||
!reduceMotion && 'will-change-transform',
|
||||
)}
|
||||
>
|
||||
{isLoading && campaigns.length === 0
|
||||
? Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className={cn('shrink-0', CARD_WIDTH_CLASS)}>
|
||||
<CampaignCardSkeleton />
|
||||
</div>
|
||||
))
|
||||
: doubledPool.map((campaign, i) => (
|
||||
<div
|
||||
key={i < campaigns.length ? campaign.aTag : `${campaign.aTag}-dup`}
|
||||
aria-hidden={i >= campaigns.length ? true : undefined}
|
||||
className={cn('shrink-0', CARD_WIDTH_CLASS)}
|
||||
>
|
||||
<CampaignCard campaign={campaign} variant="compact" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default VenezuelaReliefShowcase;
|
||||
@@ -69,8 +69,17 @@ export function parseCampaignEvents(
|
||||
interface UseCampaignsOptions {
|
||||
/** Optional ISO 3166-1 alpha-2 country filter (`i` tag). */
|
||||
countryCode?: string;
|
||||
/**
|
||||
* Optional category `t`-tag filter. When set, only campaigns carrying at
|
||||
* least one of these slugs are returned (relay-side set membership via
|
||||
* the indexed `#t` filter). Combined with {@link countryCode} as a
|
||||
* logical AND at the relay.
|
||||
*/
|
||||
categories?: string[];
|
||||
/** Maximum number of events to fetch from relays. Default: 60. */
|
||||
limit?: number;
|
||||
/** Only return campaigns created at or after this Unix timestamp (seconds). */
|
||||
since?: number;
|
||||
/** Authors to fetch from, e.g. for a profile's campaigns. */
|
||||
authors?: string[];
|
||||
/** Disable the query while dependent state is unresolved. */
|
||||
@@ -111,7 +120,9 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
|
||||
const { nostr } = useNostr();
|
||||
const {
|
||||
countryCode,
|
||||
categories,
|
||||
limit = 60,
|
||||
since,
|
||||
authors,
|
||||
coordinates,
|
||||
enabled = true,
|
||||
@@ -120,17 +131,22 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
|
||||
// Stable cache key for the coordinates option; sort so order doesn't
|
||||
// change the query identity.
|
||||
const coordinatesKey = coordinates ? [...coordinates].sort().join(',') : undefined;
|
||||
// Stable cache key for the categories option, order-independent.
|
||||
const categoriesKey = categories ? [...categories].sort().join(',') : undefined;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'campaigns',
|
||||
{ countryCode, limit, authors, coordinatesKey },
|
||||
{ countryCode, categoriesKey, limit, since, authors, coordinatesKey },
|
||||
],
|
||||
enabled,
|
||||
queryFn: async (c) => {
|
||||
// Sentinel: empty allowlist = empty result. Skip the relay entirely.
|
||||
if (coordinates && coordinates.length === 0) return [] as ParsedCampaign[];
|
||||
|
||||
const categoryFilter =
|
||||
categories && categories.length > 0 ? categories : undefined;
|
||||
|
||||
// Build the relay filter(s). When `coordinates` is set, we fan out into
|
||||
// one filter per author so we can use the indexed `#d` filter cheaply;
|
||||
// a single REQ carries all the sub-filters server-side.
|
||||
@@ -154,11 +170,15 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
|
||||
filters = Array.from(byAuthor, ([author, dTags]) => {
|
||||
const f: NostrFilter = { kinds: [CAMPAIGN_KIND], authors: [author], '#d': dTags };
|
||||
if (countryCode) f['#i'] = [createCountryIdentifier(countryCode)];
|
||||
if (categoryFilter) f['#t'] = categoryFilter;
|
||||
if (since !== undefined) f.since = since;
|
||||
return f;
|
||||
});
|
||||
} else {
|
||||
const filter: NostrFilter = { kinds: [CAMPAIGN_KIND], limit };
|
||||
if (countryCode) filter['#i'] = [createCountryIdentifier(countryCode)];
|
||||
if (categoryFilter) filter['#t'] = categoryFilter;
|
||||
if (since !== undefined) filter.since = since;
|
||||
if (authors && authors.length > 0) filter.authors = authors;
|
||||
filters = [filter];
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useCampaign } from '@/hooks/useCampaign';
|
||||
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { satsToUsd } from '@/lib/formatCampaignAmount';
|
||||
import {
|
||||
VENEZUELA_RELIEF_CAMPAIGN_IDENTIFIER,
|
||||
VENEZUELA_RELIEF_CAMPAIGN_PUBKEY,
|
||||
} from '@/lib/venezuelaRelief';
|
||||
|
||||
/** Live fundraising snapshot for the baked-in Venezuela relief campaign. */
|
||||
export interface VenezuelaReliefGoalData {
|
||||
/** True while the campaign or its donation totals are still loading. */
|
||||
isLoading: boolean;
|
||||
/** Sats raised so far (cumulative amount ever sent to the address). */
|
||||
raisedSats: number;
|
||||
/** USD equivalent of {@link raisedSats}, or `undefined` if no BTC price. */
|
||||
raisedUsd: number | undefined;
|
||||
/** Campaign goal in whole USD (per NIP.md Kind 33863), if set. */
|
||||
goalUsd: number | undefined;
|
||||
/** Number of distinct donations, if known. */
|
||||
donationCount: number;
|
||||
/** Goal completion 0–100, clamped, or `undefined` if no goal/price. */
|
||||
percent: number | undefined;
|
||||
/** Live BTC/USD price, for sats↔USD formatting at the call site. */
|
||||
btcPrice: number | undefined;
|
||||
/** True once we have a real campaign + non-loading totals to show. */
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the baked-in Venezuela relief campaign (`terremoto-venezuela`,
|
||||
* kind 33863) and its live donation totals, shared by the home hero
|
||||
* ({@link VenezuelaReliefBanner}), the session popup
|
||||
* ({@link VenezuelaReliefPopup}), and the dedicated page
|
||||
* ({@link VenezuelaReliefPage}) so all three render the same goal/progress.
|
||||
*
|
||||
* Donation totals come from the same on-chain balance lookup the campaign
|
||||
* detail page uses ({@link useCampaignDonations}); no polling here, since
|
||||
* these surfaces are ambient rather than the primary donate destination.
|
||||
*/
|
||||
export function useVenezuelaReliefCampaign(): VenezuelaReliefGoalData {
|
||||
const { data: campaign, isLoading: campaignLoading } = useCampaign({
|
||||
pubkey: VENEZUELA_RELIEF_CAMPAIGN_PUBKEY,
|
||||
identifier: VENEZUELA_RELIEF_CAMPAIGN_IDENTIFIER,
|
||||
});
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const { data: stats, isLoading: statsLoading } = useCampaignDonations(
|
||||
campaign ?? undefined,
|
||||
);
|
||||
|
||||
const raisedSats = stats?.totalSats ?? 0;
|
||||
const raisedUsd = satsToUsd(raisedSats, btcPrice);
|
||||
const goalUsd = campaign?.goalUsd;
|
||||
const donationCount = stats?.receipts?.length ?? 0;
|
||||
|
||||
const percent =
|
||||
goalUsd && goalUsd > 0 && raisedUsd !== undefined
|
||||
? Math.min(100, Math.round((raisedUsd / goalUsd) * 100))
|
||||
: undefined;
|
||||
|
||||
const isLoading = campaignLoading || statsLoading;
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
raisedSats,
|
||||
raisedUsd,
|
||||
goalUsd,
|
||||
donationCount,
|
||||
percent,
|
||||
btcPrice,
|
||||
hasData: !!campaign && !statsLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { fetchAddressData } from '@/lib/bitcoin';
|
||||
import { satsToUsd } from '@/lib/formatCampaignAmount';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
import {
|
||||
VENEZUELA_EARTHQUAKE_TIMESTAMP,
|
||||
VENEZUELA_RELIEF_CATEGORIES,
|
||||
VENEZUELA_RELIEF_COUNTRY,
|
||||
VENEZUELA_RELIEF_PINNED_COORDINATES,
|
||||
} from '@/lib/venezuelaRelief';
|
||||
|
||||
/** Live snapshot of the Venezuela relief showcase. */
|
||||
export interface VenezuelaReliefData {
|
||||
/** True while the campaign list or its aggregate totals are still loading. */
|
||||
isLoading: boolean;
|
||||
/** Every matching campaign (Venezuela + a relief category), newest first. */
|
||||
campaigns: ParsedCampaign[];
|
||||
/** Number of matching campaigns. */
|
||||
campaignCount: number;
|
||||
/**
|
||||
* Aggregate sats raised across all matching on-chain campaigns
|
||||
* (sum of `chain_stats.funded_txo_sum`). Silent-payment campaigns
|
||||
* contribute 0 by design (donations are unlinkable).
|
||||
*/
|
||||
raisedSats: number;
|
||||
/** USD equivalent of {@link raisedSats}, or `undefined` if no BTC price. */
|
||||
raisedUsd: number | undefined;
|
||||
/** Live BTC/USD price, for sats↔USD formatting at the call site. */
|
||||
btcPrice: number | undefined;
|
||||
/** True once we have at least one campaign and non-loading totals. */
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every Venezuela-located campaign tagged for relief
|
||||
* (`humanitarian-aid` or `emergency-relief`) and created at or after the
|
||||
* earthquake ({@link VENEZUELA_EARTHQUAKE_TIMESTAMP}), then aggregates
|
||||
* their live on-chain donation totals. Shared by the home hero
|
||||
* ({@link VenezuelaReliefBanner}), the session popup
|
||||
* ({@link VenezuelaReliefPopup}), and the dedicated page
|
||||
* ({@link VenezuelaReliefPage}) so all three render the same showcase and
|
||||
* progress.
|
||||
*
|
||||
* Aggregate totals mirror {@link useProfileCampaignStats}: one Esplora
|
||||
* `/address` balance lookup per on-chain campaign, summed. No per-receipt
|
||||
* `/tx` fan-out — these ambient surfaces only need the headline number.
|
||||
*
|
||||
* Pinned campaigns ({@link VENEZUELA_RELIEF_PINNED_COORDINATES}) — the
|
||||
* flagship effort that predates the geo-tagging convention — are fetched
|
||||
* by coordinate and merged in ahead of the filtered results, deduped by
|
||||
* `aTag` so a pinned campaign that also matches the filter isn't doubled.
|
||||
*/
|
||||
export function useVenezuelaReliefCampaigns(): VenezuelaReliefData {
|
||||
const { config } = useAppContext();
|
||||
const { esploraApis } = config;
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
|
||||
const campaignsQuery = useCampaigns({
|
||||
countryCode: VENEZUELA_RELIEF_COUNTRY,
|
||||
categories: [...VENEZUELA_RELIEF_CATEGORIES],
|
||||
since: VENEZUELA_EARTHQUAKE_TIMESTAMP,
|
||||
limit: 60,
|
||||
});
|
||||
|
||||
// Always-included flagship campaign(s), fetched by exact coordinate so
|
||||
// they appear even without the `iso3166:VE` country tag the filter needs.
|
||||
const pinnedQuery = useCampaigns({
|
||||
coordinates: [...VENEZUELA_RELIEF_PINNED_COORDINATES],
|
||||
});
|
||||
|
||||
// Pinned first, then the filtered results, deduped by addressable coord.
|
||||
const campaigns: ParsedCampaign[] = (() => {
|
||||
const seen = new Set<string>();
|
||||
const merged: ParsedCampaign[] = [];
|
||||
for (const c of [...(pinnedQuery.data ?? []), ...(campaignsQuery.data ?? [])]) {
|
||||
if (seen.has(c.aTag)) continue;
|
||||
seen.add(c.aTag);
|
||||
merged.push(c);
|
||||
}
|
||||
return merged;
|
||||
})();
|
||||
|
||||
// Fan out: one balance lookup per on-chain campaign address. Silent-payment
|
||||
// campaigns are excluded (donations are unlinkable by design).
|
||||
const onchainCampaigns = campaigns.flatMap((c) => {
|
||||
const address = c.wallets?.onchain?.value;
|
||||
return address ? [{ campaign: c, address }] : [];
|
||||
});
|
||||
const balanceQueries = useQueries({
|
||||
queries: onchainCampaigns.map(({ address }) => ({
|
||||
// Share the cache key with useCampaignDonations / useProfileCampaignStats
|
||||
// so all surfaces refresh together when a donation invalidates
|
||||
// ['bitcoin-balance'].
|
||||
queryKey: ['bitcoin-balance', 'campaign', esploraApis, address],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
fetchAddressData(address, esploraApis, signal),
|
||||
staleTime: 60_000,
|
||||
enabled: !!address,
|
||||
})),
|
||||
});
|
||||
|
||||
const raisedSats = balanceQueries.reduce(
|
||||
(sum, q) => sum + (q.data?.totalReceived ?? 0),
|
||||
0,
|
||||
);
|
||||
const raisedUsd = satsToUsd(raisedSats, btcPrice);
|
||||
|
||||
const balancesLoading = balanceQueries.some((q) => q.isLoading);
|
||||
const isLoading =
|
||||
campaignsQuery.isLoading || pinnedQuery.isLoading || balancesLoading;
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
campaigns,
|
||||
campaignCount: campaigns.length,
|
||||
raisedSats,
|
||||
raisedUsd,
|
||||
btcPrice,
|
||||
hasData: campaigns.length > 0 && !balancesLoading,
|
||||
};
|
||||
}
|
||||
@@ -800,36 +800,3 @@
|
||||
.hero-node-pulse { animation: none; opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Transient highlight flash for the Venezuela relief donate panel /
|
||||
section, triggered by the page's "Donate to relief" CTA. A pulsing
|
||||
ring drawn with box-shadow (not `ring`) so it shows even when the
|
||||
target clips its overflow, and an offset gap so it reads as a frame. */
|
||||
.relief-donate-flash {
|
||||
animation: relief-donate-flash 2s ease-out;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
@keyframes relief-donate-flash {
|
||||
0%, 70% {
|
||||
box-shadow:
|
||||
0 0 0 3px hsl(var(--background)),
|
||||
0 0 0 7px hsl(var(--primary) / 0.75),
|
||||
0 0 22px 7px hsl(var(--primary) / 0.45);
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 3px hsl(var(--background) / 0),
|
||||
0 0 0 7px hsl(var(--primary) / 0),
|
||||
0 0 22px 7px hsl(var(--primary) / 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* No pulse — hold a steady ring for the lifetime of the class. */
|
||||
.relief-donate-flash {
|
||||
animation: none;
|
||||
box-shadow:
|
||||
0 0 0 3px hsl(var(--background)),
|
||||
0 0 0 7px hsl(var(--primary) / 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
+50
-20
@@ -4,38 +4,68 @@
|
||||
* Centralised here so the home-page hero ({@link VenezuelaReliefBanner}),
|
||||
* the session popup ({@link VenezuelaReliefPopup}), and the dedicated
|
||||
* shareable page ({@link VenezuelaReliefPage}) all reference the same
|
||||
* route, photo gallery, and deep-link, keeping copy and links in sync.
|
||||
* route, photo gallery, and campaign filter, keeping copy and links in
|
||||
* sync.
|
||||
*
|
||||
* Rather than baking in a single campaign, the appeal now showcases
|
||||
* *every* Venezuela-located campaign tagged for relief (see
|
||||
* {@link VENEZUELA_RELIEF_COUNTRY} + {@link VENEZUELA_RELIEF_CATEGORIES}),
|
||||
* resolved live via `useVenezuelaReliefCampaigns`. The donate CTA on every
|
||||
* surface points at the dedicated relief page, which shows the full
|
||||
* showcase.
|
||||
*
|
||||
* When the relief response winds down, removing the popup mount in
|
||||
* `App.tsx`, the `<VenezuelaReliefBanner />` line in `CampaignsPage`, and
|
||||
* the `/venezuela-relief` route in `AppRouter` retires the whole appeal.
|
||||
* `AppRouter.tsx`, the `<VenezuelaReliefBanner />` line in `CampaignsPage`,
|
||||
* and the `/venezuela-relief` route in `AppRouter` retires the whole appeal.
|
||||
*/
|
||||
|
||||
/** Public route for the dedicated relief page (shareable). */
|
||||
export const VENEZUELA_RELIEF_PATH = '/venezuela-relief';
|
||||
|
||||
/**
|
||||
* The specific relief campaign baked into the appeal — `terremoto-venezuela`
|
||||
* (kind 33863). The hero, popup, and page resolve this `(pubkey, identifier)`
|
||||
* coordinate to surface the campaign's live raised/goal progress, turning
|
||||
* each surface into an info + donation hybrid. The donate CTA
|
||||
* ({@link VENEZUELA_DONATE_PATH}) deep-links to this same campaign's naddr.
|
||||
* ISO 3166-1 alpha-2 country code the appeal is scoped to. Campaigns are
|
||||
* matched on their NIP-73 `i` tag (`iso3166:VE`); see
|
||||
* {@link createCountryIdentifier}.
|
||||
*/
|
||||
export const VENEZUELA_RELIEF_CAMPAIGN_PUBKEY =
|
||||
'7a303d62d6c9d2f0cabe2ca713a392f3ec4b1fab815ea60b79fe15aca274c71c';
|
||||
|
||||
/** The relief campaign's `d` tag (slug). */
|
||||
export const VENEZUELA_RELIEF_CAMPAIGN_IDENTIFIER = 'terremoto-venezuela';
|
||||
export const VENEZUELA_RELIEF_COUNTRY = 'VE';
|
||||
|
||||
/**
|
||||
* Deep-link straight to the specific Venezuela earthquake relief campaign
|
||||
* (`terremoto-venezuela`, kind 33863). Baked in as the donate CTA target
|
||||
* for the hero, popup, and dedicated page so donors land on the campaign's
|
||||
* detail page rather than a filtered browse. NIP-19 identifiers route at
|
||||
* the URL root (`/:nip19`), handled by `NIP19Page`.
|
||||
* Campaign category `t`-tag slugs that qualify a Venezuela campaign for
|
||||
* the relief showcase. A campaign needs *either* tag (logical OR) — the
|
||||
* relay-indexed `#t` filter is a set-membership match. These slugs come
|
||||
* from the curated picker in {@link CAMPAIGN_CATEGORIES}.
|
||||
*/
|
||||
export const VENEZUELA_DONATE_PATH =
|
||||
'/naddr1qvzqqqyygupzq73s843ddjwj7r9tut98zw3e9ulvfv06hq275c9hnls44j38f3cuqqfhgetjwfjk6mm5dukhvetwv4a82etvvykrc9yj';
|
||||
export const VENEZUELA_RELIEF_CATEGORIES: readonly string[] = [
|
||||
'humanitarian-aid',
|
||||
'emergency-relief',
|
||||
];
|
||||
|
||||
/**
|
||||
* Unix timestamp (seconds) of the Venezuela earthquake. The showcase only
|
||||
* surfaces campaigns *created at or after* this moment — pre-existing
|
||||
* Venezuela humanitarian/relief campaigns aren't part of *this* quake
|
||||
* response and would dilute the appeal. Set to 2026-06-25T00:00:00Z, the
|
||||
* day the response began.
|
||||
*/
|
||||
export const VENEZUELA_EARTHQUAKE_TIMESTAMP = Math.floor(
|
||||
Date.UTC(2026, 5, 25) / 1000,
|
||||
);
|
||||
|
||||
/**
|
||||
* Addressable coordinates (`33863:<pubkey>:<d>`) that are always included
|
||||
* in the relief showcase, regardless of whether they match the
|
||||
* country/category/date filter above.
|
||||
*
|
||||
* The flagship `terremoto-venezuela` campaign ("EARTHQUAKE STUDENT RESCUE
|
||||
* BRIGADES") is tagged `emergency-relief` but was published without an
|
||||
* `iso3166:VE` country `i` tag, so the geo filter drops it. Pin it here so
|
||||
* the canonical relief effort always leads the showcase. Coordinates are
|
||||
* de-duplicated against the live query results, so a campaign that *also*
|
||||
* matches the filter won't appear twice.
|
||||
*/
|
||||
export const VENEZUELA_RELIEF_PINNED_COORDINATES: readonly string[] = [
|
||||
'33863:7a303d62d6c9d2f0cabe2ca713a392f3ec4b1fab815ea60b79fe15aca274c71c:terremoto-venezuela',
|
||||
];
|
||||
|
||||
/**
|
||||
* Ordered set of news photographs from the Venezuela earthquake that
|
||||
|
||||
+3
-2
@@ -929,7 +929,6 @@
|
||||
"title": "فنزويلا بحاجة <0>إليك</0>",
|
||||
"body": "ضرب زلزال قويّ شمال فنزويلا، وكانت كاراكاس ولا غوايرا الأشدّ تضرّرًا. لا تستطيع العائلات هناك تجاوز هذه المحنة وحدها. تبرّعك يذهب مباشرة إلى حملات الإغاثة على الأرض، فيحوّل رحمتك اليوم إلى مأوى وطعام وأمل.",
|
||||
"donate": "تبرّع للإغاثة",
|
||||
"startCampaign": "اجمع التبرّعات من أجل فنزويلا",
|
||||
"credit": "صور من كاراكاس، فنزويلا. (AP؛ AFP via Getty Images)",
|
||||
"share": "مشاركة",
|
||||
"shareTitle": "إغاثة زلزال فنزويلا على Agora",
|
||||
@@ -941,7 +940,9 @@
|
||||
"pageEyebrow": "نداء طوارئ",
|
||||
"pageHow": "كيف يساعد تبرّعك",
|
||||
"pageHowBody": "Agora غير حافظ: يذهب البيتكوين الخاص بك مباشرة من محفظتك إلى عنوان حملة الإغاثة. لا منصة تقتطع حصة، ولا حافظ يحمل الأموال، ولا أحد يحتاج إلى إذن ليعطي أو يأخذ. كل حملة أدناه يديرها أشخاص يستجيبون للزلزال على الأرض.",
|
||||
"goalOf": "من هدف {{amount}}"
|
||||
"showcaseTitle": "حملات الإغاثة",
|
||||
"campaignCount_one": "{{count}} حملة إغاثة",
|
||||
"campaignCount_other": "{{count}} حملة إغاثة"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "لماذا {{appName}}",
|
||||
|
||||
+3
-2
@@ -1465,7 +1465,6 @@
|
||||
"title": "Venezuela needs <0>you</0>",
|
||||
"body": "A powerful earthquake has struck northern Venezuela, hitting Caracas and La Guaira hardest. Families there can't get through this alone. Your donation goes straight to relief campaigns on the ground, turning your compassion into shelter, food, and hope today.",
|
||||
"donate": "Donate to relief",
|
||||
"startCampaign": "Raise funds for Venezuela",
|
||||
"credit": "Photos from Caracas, Venezuela. (AP; AFP via Getty Images)",
|
||||
"share": "Share",
|
||||
"shareTitle": "Venezuela earthquake relief on Agora",
|
||||
@@ -1477,7 +1476,9 @@
|
||||
"pageEyebrow": "Emergency appeal",
|
||||
"pageHow": "How your donation helps",
|
||||
"pageHowBody": "Agora is non-custodial: your Bitcoin goes straight from your wallet to a relief campaign's address. No platform takes a cut, no custodian holds the funds, and no one needs permission to give or receive. Every campaign below is run by people responding to the quake on the ground.",
|
||||
"goalOf": "of {{amount}} goal"
|
||||
"showcaseTitle": "Relief campaigns",
|
||||
"campaignCount_one": "{{count}} relief campaign",
|
||||
"campaignCount_other": "{{count}} relief campaigns"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "Why {{appName}}",
|
||||
|
||||
+3
-2
@@ -929,7 +929,6 @@
|
||||
"title": "Venezuela <0>te</0> necesita",
|
||||
"body": "Un fuerte terremoto ha sacudido el norte de Venezuela, golpeando con mayor dureza a Caracas y La Guaira. Las familias de allí no pueden salir adelante solas. Tu donación va directa a las campañas de ayuda sobre el terreno, transformando hoy mismo tu compasión en refugio, alimento y esperanza.",
|
||||
"donate": "Donar a la ayuda",
|
||||
"startCampaign": "Recauda fondos para Venezuela",
|
||||
"credit": "Fotos de Caracas, Venezuela. (AP; AFP vía Getty Images)",
|
||||
"share": "Compartir",
|
||||
"shareTitle": "Ayuda para el terremoto de Venezuela en Agora",
|
||||
@@ -941,7 +940,9 @@
|
||||
"pageEyebrow": "Llamado de emergencia",
|
||||
"pageHow": "Cómo ayuda tu donación",
|
||||
"pageHowBody": "Agora no custodia los fondos: tu Bitcoin va directo desde tu monedero a la dirección de una campaña de ayuda. Ninguna plataforma se lleva una comisión, ningún custodio retiene los fondos y nadie necesita permiso para dar o recibir. Cada campaña a continuación está gestionada por personas que responden al terremoto sobre el terreno.",
|
||||
"goalOf": "de {{amount}} objetivo"
|
||||
"showcaseTitle": "Campañas de ayuda",
|
||||
"campaignCount_one": "{{count}} campaña de ayuda",
|
||||
"campaignCount_other": "{{count}} campañas de ayuda"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "Por qué {{appName}}",
|
||||
|
||||
+3
-2
@@ -929,7 +929,6 @@
|
||||
"title": "Venezuela به <0>شما</0> نیاز دارد",
|
||||
"body": "زمینلرزهای ویرانگر شمال Venezuela را لرزانده و بیش از همه Caracas و La Guaira را در هم کوبیده است. خانوادههای آنجا نمیتوانند بهتنهایی از این مصیبت عبور کنند. کمک شما مستقیماً به کمپینهای امدادرسانی در محل میرسد و دلسوزیتان را همین امروز به سرپناه، غذا و امید بدل میکند.",
|
||||
"donate": "کمک به امدادرسانی",
|
||||
"startCampaign": "جمعآوری کمک برای Venezuela",
|
||||
"credit": "تصاویری از Caracas، ونزوئلا. (AP؛ AFP via Getty Images)",
|
||||
"share": "همرسانی",
|
||||
"shareTitle": "امدادرسانی به زلزلهٔ Venezuela در Agora",
|
||||
@@ -941,7 +940,9 @@
|
||||
"pageEyebrow": "فراخوان اضطراری",
|
||||
"pageHow": "کمک شما چگونه یاری میرساند",
|
||||
"pageHowBody": "Agora غیرامانی است: بیتکوین شما مستقیماً از کیفپولتان به نشانی کمپین امدادرسانی میرود. هیچ پلتفرمی سهمی برنمیدارد، هیچ امانتداری وجوه را نگه نمیدارد، و هیچکس برای دادن یا گرفتن به اجازه نیاز ندارد. هر کمپین زیر را افرادی اداره میکنند که در محل به زلزله واکنش نشان میدهند.",
|
||||
"goalOf": "از هدف {{amount}}"
|
||||
"showcaseTitle": "کمپینهای امدادرسانی",
|
||||
"campaignCount_one": "{{count}} کمپین امدادرسانی",
|
||||
"campaignCount_other": "{{count}} کمپین امدادرسانی"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "چرا {{appName}}",
|
||||
|
||||
+3
-2
@@ -1371,7 +1371,6 @@
|
||||
"title": "Le Venezuela a besoin de <0>vous</0>",
|
||||
"body": "Un puissant séisme a frappé le nord du Venezuela, touchant le plus durement Caracas et La Guaira. Là-bas, les familles ne peuvent pas surmonter cette épreuve seules. Votre don va directement aux campagnes de secours sur le terrain et transforme votre compassion en abri, en nourriture et en espoir, dès aujourd'hui.",
|
||||
"donate": "Faire un don aux secours",
|
||||
"startCampaign": "Récolter des fonds pour le Venezuela",
|
||||
"credit": "Photos de Caracas, au Venezuela. (AP ; AFP via Getty Images)",
|
||||
"share": "Partager",
|
||||
"shareTitle": "Aide au séisme au Venezuela sur Agora",
|
||||
@@ -1383,7 +1382,9 @@
|
||||
"pageEyebrow": "Appel d'urgence",
|
||||
"pageHow": "Comment votre don aide",
|
||||
"pageHowBody": "Agora est non dépositaire : votre Bitcoin va directement de votre portefeuille à l'adresse d'une campagne de secours. Aucune plateforme ne prélève de commission, aucun dépositaire ne détient les fonds et personne n'a besoin de permission pour donner ou recevoir. Chaque campagne ci-dessous est menée par des personnes qui répondent au séisme sur le terrain.",
|
||||
"goalOf": "sur un objectif de {{amount}}"
|
||||
"showcaseTitle": "Campagnes de secours",
|
||||
"campaignCount_one": "{{count}} campagne de secours",
|
||||
"campaignCount_other": "{{count}} campagnes de secours"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "Pourquoi {{appName}}",
|
||||
|
||||
+3
-2
@@ -1375,7 +1375,6 @@
|
||||
"title": "Venezuela को <0>आपकी</0> ज़रूरत है",
|
||||
"body": "उत्तरी Venezuela में एक भीषण भूकंप आया है, जिसने Caracas और La Guaira को सबसे ज़्यादा तबाह किया है। वहाँ के परिवार इस मुश्किल वक़्त से अकेले नहीं उबर सकते। आपका दान सीधे ज़मीन पर चल रहे राहत अभियानों तक पहुँचता है, और आज ही आपकी करुणा को आसरा, भोजन और उम्मीद में बदल देता है।",
|
||||
"donate": "राहत के लिए दान करें",
|
||||
"startCampaign": "Venezuela के लिए धन जुटाएँ",
|
||||
"credit": "Caracas, Venezuela की तस्वीरें। (AP; AFP via Getty Images)",
|
||||
"share": "साझा करें",
|
||||
"shareTitle": "Agora पर Venezuela भूकंप राहत",
|
||||
@@ -1387,7 +1386,9 @@
|
||||
"pageEyebrow": "आपातकालीन अपील",
|
||||
"pageHow": "आपका दान कैसे मदद करता है",
|
||||
"pageHowBody": "Agora नॉन-कस्टोडियल है: आपका Bitcoin सीधे आपके वॉलेट से किसी राहत अभियान के पते पर जाता है। कोई प्लेटफ़ॉर्म हिस्सा नहीं काटता, कोई कस्टोडियन धन नहीं रखता, और देने या पाने के लिए किसी को अनुमति की ज़रूरत नहीं। नीचे दिया गया हर अभियान ज़मीन पर भूकंप से जूझ रहे लोगों द्वारा चलाया जा रहा है।",
|
||||
"goalOf": "{{amount}} के लक्ष्य में से"
|
||||
"showcaseTitle": "राहत अभियान",
|
||||
"campaignCount_one": "{{count}} राहत अभियान",
|
||||
"campaignCount_other": "{{count}} राहत अभियान"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "क्यों {{appName}}",
|
||||
|
||||
+3
-2
@@ -1375,7 +1375,6 @@
|
||||
"title": "Venezuela membutuhkan <0>Anda</0>",
|
||||
"body": "Gempa bumi dahsyat telah mengguncang Venezuela bagian utara, dengan dampak terparah di Caracas dan La Guaira. Keluarga-keluarga di sana tidak bisa melewati cobaan ini sendirian. Donasi Anda langsung mengalir ke kampanye bantuan di lapangan, mengubah belas kasih Anda menjadi tempat berlindung, makanan, dan harapan hari ini.",
|
||||
"donate": "Donasi untuk bantuan",
|
||||
"startCampaign": "Galang dana untuk Venezuela",
|
||||
"credit": "Foto dari Caracas, Venezuela. (AP; AFP via Getty Images)",
|
||||
"share": "Bagikan",
|
||||
"shareTitle": "Bantuan gempa Venezuela di Agora",
|
||||
@@ -1387,7 +1386,9 @@
|
||||
"pageEyebrow": "Seruan darurat",
|
||||
"pageHow": "Bagaimana donasi Anda membantu",
|
||||
"pageHowBody": "Agora bersifat non-kustodial: Bitcoin Anda langsung mengalir dari dompet Anda ke alamat kampanye bantuan. Tidak ada platform yang mengambil potongan, tidak ada kustodian yang menahan dana, dan tidak ada yang perlu izin untuk memberi atau menerima. Setiap kampanye di bawah ini dijalankan oleh orang-orang yang merespons gempa di lapangan.",
|
||||
"goalOf": "dari target {{amount}}"
|
||||
"showcaseTitle": "Kampanye bantuan",
|
||||
"campaignCount_one": "{{count}} kampanye bantuan",
|
||||
"campaignCount_other": "{{count}} kampanye bantuan"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "Mengapa {{appName}}",
|
||||
|
||||
+3
-2
@@ -929,7 +929,6 @@
|
||||
"title": "Venezuela ត្រូវការ<0>អ្នក</0>",
|
||||
"body": "រញ្ជួយដីដ៏ខ្លាំងមួយបានវាយប្រហារភាគខាងជើងនៃ Venezuela ដោយធ្វើឱ្យ Caracas និង La Guaira រងគ្រោះធ្ងន់ធ្ងរបំផុត។ គ្រួសារនៅទីនោះមិនអាចឆ្លងផុតវិបត្តិនេះតែម្នាក់ឯងបានឡើយ។ ការបរិច្ចាគរបស់អ្នកនឹងទៅដល់ដោយផ្ទាល់ទៅកាន់យុទ្ធនាការសង្គ្រោះនៅនឹងកន្លែង ប្រែក្លាយក្ដីមេត្តាករុណារបស់អ្នកទៅជាជម្រក អាហារ និងក្ដីសង្ឃឹមនៅថ្ងៃនេះ។",
|
||||
"donate": "បរិច្ចាគដើម្បីសង្គ្រោះ",
|
||||
"startCampaign": "ប្រមូលមូលនិធិសម្រាប់ Venezuela",
|
||||
"credit": "រូបថតពី Caracas ប្រទេសវេណេស៊ុយអេឡា។ (AP; AFP via Getty Images)",
|
||||
"share": "ចែករំលែក",
|
||||
"shareTitle": "ការសង្គ្រោះរញ្ជួយដី Venezuela នៅលើ Agora",
|
||||
@@ -941,7 +940,9 @@
|
||||
"pageEyebrow": "ការអំពាវនាវបន្ទាន់",
|
||||
"pageHow": "ការបរិច្ចាគរបស់អ្នកជួយយ៉ាងដូចម្ដេច",
|
||||
"pageHowBody": "Agora មិនថែរក្សាមូលនិធិទេ៖ Bitcoin របស់អ្នកទៅដោយផ្ទាល់ពីកាបូបរបស់អ្នកទៅកាន់អាសយដ្ឋានរបស់យុទ្ធនាការសង្គ្រោះ។ គ្មានវេទិកាកាត់កម្រៃ គ្មានអ្នកថែរក្សាកាន់មូលនិធិ ហើយគ្មាននរណាម្នាក់ត្រូវការការអនុញ្ញាតដើម្បីផ្ដល់ ឬទទួលឡើយ។ រាល់យុទ្ធនាការខាងក្រោមនេះ ត្រូវបានដំណើរការដោយមនុស្សដែលកំពុងឆ្លើយតបនឹងរញ្ជួយដីនៅនឹងកន្លែង។",
|
||||
"goalOf": "នៃគោលដៅ {{amount}}"
|
||||
"showcaseTitle": "យុទ្ធនាការសង្គ្រោះ",
|
||||
"campaignCount_one": "យុទ្ធនាការសង្គ្រោះ {{count}}",
|
||||
"campaignCount_other": "យុទ្ធនាការសង្គ្រោះ {{count}}"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "ហេតុអ្វី {{appName}}",
|
||||
|
||||
+3
-2
@@ -929,7 +929,6 @@
|
||||
"title": "Venezuela <0>تاسو</0> ته اړتیا لري",
|
||||
"body": "یوې زورورې زلزلې د Venezuela شمال ولړزاوه، چې تر ټولو سخت زیان يې Caracas او La Guaira ته ورساوه. هلته کورنۍ يوازې له دې کړاو څخه نشي خلاصېدلی. ستاسو ډالۍ مستقیماً پر ځمکه فعالو مرستندویه کمپاینونو ته رسیږي، او ستاسو زړهسوی نن په سرپناه، خوراک او هیله بدلوي.",
|
||||
"donate": "مرستې ته ډالۍ ورکړئ",
|
||||
"startCampaign": "د Venezuela لپاره مرسته راټوله کړئ",
|
||||
"credit": "انځورونه له Caracas، Venezuela څخه. (AP؛ AFP via Getty Images)",
|
||||
"share": "شریکول",
|
||||
"shareTitle": "په Agora کې د Venezuela د زلزلې مرسته",
|
||||
@@ -941,7 +940,9 @@
|
||||
"pageEyebrow": "بیړنۍ غوښتنه",
|
||||
"pageHow": "ستاسو ډالۍ څنګه مرسته کوي",
|
||||
"pageHowBody": "Agora غیر حافظوي ده: ستاسو Bitcoin مستقیماً ستاسو له کیفپيسې څخه د مرستندویه کمپاین پتې ته ځي. هیڅ پلتفارم برخه نه اخلي، هیڅ حافظ پيسې نه ساتي، او هیڅوک د ورکولو یا ترلاسه کولو لپاره اجازې ته اړتیا نه لري. لاندې هر کمپاین د هغو خلکو لخوا چلیږي چې پر ځمکه د زلزلې غبرګون ښیي.",
|
||||
"goalOf": "د {{amount}} هدف څخه"
|
||||
"showcaseTitle": "د مرستې کمپاینونه",
|
||||
"campaignCount_one": "{{count}} د مرستې کمپاین",
|
||||
"campaignCount_other": "{{count}} د مرستې کمپاینونه"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "ولې {{appName}}",
|
||||
|
||||
+3
-2
@@ -1371,7 +1371,6 @@
|
||||
"title": "A Venezuela precisa de <0>você</0>",
|
||||
"body": "Um forte terremoto atingiu o norte da Venezuela, castigando com mais força Caracas e La Guaira. As famílias de lá não conseguem superar isso sozinhas. Sua doação vai diretamente para as campanhas de ajuda no local, transformando hoje a sua compaixão em abrigo, comida e esperança.",
|
||||
"donate": "Doar para a ajuda",
|
||||
"startCampaign": "Arrecadar fundos para a Venezuela",
|
||||
"credit": "Fotos de Caracas, Venezuela. (AP; AFP via Getty Images)",
|
||||
"share": "Compartilhar",
|
||||
"shareTitle": "Ajuda às vítimas do terremoto na Venezuela no Agora",
|
||||
@@ -1383,7 +1382,9 @@
|
||||
"pageEyebrow": "Apelo de emergência",
|
||||
"pageHow": "Como sua doação ajuda",
|
||||
"pageHowBody": "O Agora é não custodial: seu Bitcoin vai direto da sua carteira para o endereço de uma campanha de ajuda. Nenhuma plataforma fica com uma parte, nenhum custodiante segura os fundos e ninguém precisa de permissão para doar ou receber. Cada campanha abaixo é conduzida por pessoas que respondem ao terremoto no local.",
|
||||
"goalOf": "de {{amount}} da meta"
|
||||
"showcaseTitle": "Campanhas de ajuda",
|
||||
"campaignCount_one": "{{count}} campanha de ajuda",
|
||||
"campaignCount_other": "{{count}} campanhas de ajuda"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "Por que o {{appName}}",
|
||||
|
||||
+3
-2
@@ -1371,7 +1371,6 @@
|
||||
"title": "Венесуэле нужна <0>ваша</0> помощь",
|
||||
"body": "Мощное землетрясение обрушилось на север Венесуэлы, сильнее всего пострадали Caracas и La Guaira. Семьям там не справиться в одиночку. Ваше пожертвование идёт напрямую в кампании помощи на месте, превращая ваше сострадание в кров, еду и надежду уже сегодня.",
|
||||
"donate": "Пожертвовать на помощь",
|
||||
"startCampaign": "Собрать средства для Венесуэлы",
|
||||
"credit": "Фото: жители обнимаются рядом с обрушившимся зданием в Caracas. (AFP via Getty Images)",
|
||||
"share": "Поделиться",
|
||||
"shareTitle": "Помощь Венесуэле после землетрясения на Agora",
|
||||
@@ -1383,7 +1382,9 @@
|
||||
"pageEyebrow": "Экстренный призыв",
|
||||
"pageHow": "Как помогает ваше пожертвование",
|
||||
"pageHowBody": "Agora не хранит ваши средства: ваш Bitcoin идёт напрямую из вашего кошелька на адрес кампании помощи. Платформа не берёт комиссию, хранитель не держит средства, и никому не нужно разрешение, чтобы давать или получать. Каждая кампания ниже ведётся людьми, которые реагируют на землетрясение на месте.",
|
||||
"goalOf": "из {{amount}} цели"
|
||||
"showcaseTitle": "Кампании помощи",
|
||||
"campaignCount_one": "{{count}} кампания помощи",
|
||||
"campaignCount_other": "{{count}} кампаний помощи"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "Почему {{appName}}",
|
||||
|
||||
+4
-3
@@ -929,7 +929,6 @@
|
||||
"title": "Venezuela <0>inokuda</0>",
|
||||
"body": "Kudengenyeka kwenyika kune simba kwakarova kuchamhembe kweVenezuela, kuchikuvadza Caracas neLa Guaira zvakanyanya. Mhuri dziri ikoko hadzigoni kupfuura mudambudziko iri dzega. Chipo chako chinonanga kumishandirapamwe yekubatsira iri panzvimbo, ichishandura tsitsi dzako kuva pekugara, chikafu, netariro nhasi.",
|
||||
"donate": "Ipa kubatsira",
|
||||
"startCampaign": "Unganidza mari yeVenezuela",
|
||||
"credit": "Mufananidzo: vagari vachimbundirana pedyo nechivako chakawira muCaracas. (AFP via Getty Images)",
|
||||
"share": "Govera",
|
||||
"shareTitle": "Rubatsiro rwekudengenyeka kweVenezuela paAgora",
|
||||
@@ -940,8 +939,10 @@
|
||||
"seoDescription": "Kudengenyeka kwenyika kune simba kwakarova kuchamhembe kweVenezuela. Ipa Bitcoin yakananga kumishandirapamwe yekubatsira iri panzvimbo paAgora. Hapana puratifomu, hapana muchengeti, hapana mvumo inodiwa.",
|
||||
"pageEyebrow": "Chikumbiro chekukurumidza",
|
||||
"pageHow": "Kuti chipo chako chinobatsira sei",
|
||||
"pageHowBody": "Agora haichengeti mari: Bitcoin yako inonanga kubva muwallet yako kuenda kukero yemushandirapamwe wekubatsira. Hapana puratifomu inotora chikamu, hapana muchengeti anobata mari, uye hapana anoda mvumo yokupa kana kugamuchira. Mushandirapamwe wose uri pasi apa unotungamirirwa nevanhu vari kupindura kudengenyeka iri panzvimbo.",
|
||||
"goalOf": "kubva pa{{amount}} chinangwa"
|
||||
"pageHowBody": "Agora haichengeti mari: Bitcoin yako inonanga kubva muwallet yako kuenda kukero yemushandirapamwe wekubatsira. Hapana puratifomu inotora chikamu, hapana muchengeti anobata mari, uye hapana anoda mvumo yokupa kana kugamuchira. Mushandirapamwe wose uri pasi apa unotungamirirwa nevanhu vari kupindura kudengenyeka iri panzvimbo.",
|
||||
"showcaseTitle": "Mishandirapamwe yekubatsira",
|
||||
"campaignCount_one": "{{count}} mushandirapamwe wekubatsira",
|
||||
"campaignCount_other": "Mishandirapamwe yekubatsira {{count}}"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "Sei {{appName}}",
|
||||
|
||||
+3
-2
@@ -1375,7 +1375,6 @@
|
||||
"title": "Venezuela <0>inakuhitaji</0>",
|
||||
"body": "Tetemeko kubwa la ardhi limeikumba kaskazini mwa Venezuela, likiathiri vibaya zaidi maeneo ya Caracas na La Guaira. Familia zilizoko huko haziwezi kupita katika dhiki hii peke yao. Mchango wako unawafikia moja kwa moja wahanga kupitia kampeni za misaada zilizoko eneo la tukio, ukigeuza huruma yako kuwa makao, chakula, na matumaini leo.",
|
||||
"donate": "Changia misaada",
|
||||
"startCampaign": "Changisha fedha kwa ajili ya Venezuela",
|
||||
"credit": "Picha: wakazi wakikumbatiana karibu na jengo lililoporomoka mjini Caracas. (AFP via Getty Images)",
|
||||
"share": "Shiriki",
|
||||
"shareTitle": "Misaada ya tetemeko la ardhi la Venezuela kwenye Agora",
|
||||
@@ -1387,7 +1386,9 @@
|
||||
"pageEyebrow": "Wito wa dharura",
|
||||
"pageHow": "Jinsi mchango wako unavyosaidia",
|
||||
"pageHowBody": "Agora haitunzi fedha: Bitcoin yako huenda moja kwa moja kutoka kwenye pochi yako hadi anwani ya kampeni ya misaada. Hakuna jukwaa linalochukua sehemu, hakuna mtunzaji anayeshikilia fedha, na hakuna mtu anayehitaji ruhusa kutoa au kupokea. Kila kampeni iliyo hapa chini inaendeshwa na watu wanaokabiliana na tetemeko hili eneo la tukio.",
|
||||
"goalOf": "kati ya lengo la {{amount}}"
|
||||
"showcaseTitle": "Kampeni za misaada",
|
||||
"campaignCount_one": "Kampeni {{count}} ya misaada",
|
||||
"campaignCount_other": "Kampeni {{count}} za misaada"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "Kwa nini {{appName}}",
|
||||
|
||||
+3
-2
@@ -1375,7 +1375,6 @@
|
||||
"title": "Venezuela <0>size</0> ihtiyaç duyuyor",
|
||||
"body": "Kuzey Venezuela'yı şiddetli bir deprem vurdu; en ağır darbeyi Caracas ve La Guaira aldı. Oradaki aileler bu acıyı tek başlarına atlatamaz. Bağışınız doğrudan sahadaki yardım kampanyalarına ulaşır ve şefkatinizi bugün barınağa, gıdaya ve umuda dönüştürür.",
|
||||
"donate": "Yardıma bağış yap",
|
||||
"startCampaign": "Venezuela için fon toplayın",
|
||||
"credit": "Fotoğraf: Caracas'ta çöken bir binanın yakınında kucaklaşan halk. (AFP via Getty Images)",
|
||||
"share": "Paylaş",
|
||||
"shareTitle": "Agora'da Venezuela deprem yardımı",
|
||||
@@ -1387,7 +1386,9 @@
|
||||
"pageEyebrow": "Acil çağrı",
|
||||
"pageHow": "Bağışınız nasıl yardımcı olur",
|
||||
"pageHowBody": "Agora emanetsizdir: Bitcoin'iniz cüzdanınızdan doğrudan bir yardım kampanyasının adresine gider. Hiçbir platform pay almaz, hiçbir emanetçi parayı tutmaz ve vermek ya da almak için kimsenin iznine ihtiyaç yoktur. Aşağıdaki her kampanya, sahada depreme yanıt veren insanlar tarafından yürütülüyor.",
|
||||
"goalOf": "{{amount}} hedefinden"
|
||||
"showcaseTitle": "Yardım kampanyaları",
|
||||
"campaignCount_one": "{{count}} yardım kampanyası",
|
||||
"campaignCount_other": "{{count}} yardım kampanyası"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "Neden {{appName}}",
|
||||
|
||||
@@ -933,7 +933,6 @@
|
||||
"title": "委內瑞拉需要<0>你</0>",
|
||||
"body": "一場強烈地震襲擊了 Venezuela 北部,其中 Caracas 與 La Guaira 受創最重。當地的家庭無法獨自撐過這場災難。你的捐款將直接送達當地的救援活動,讓你的關懷在今天就化為遮風避雨的住所、溫飽的食物,以及繼續走下去的希望。",
|
||||
"donate": "捐助救援",
|
||||
"startCampaign": "為委內瑞拉募款",
|
||||
"credit": "照片:居民在 Caracas 一棟倒塌建築附近相擁。(AFP via Getty Images)",
|
||||
"share": "分享",
|
||||
"shareTitle": "Agora 上的委內瑞拉地震救援",
|
||||
@@ -945,7 +944,9 @@
|
||||
"pageEyebrow": "緊急募款",
|
||||
"pageHow": "你的捐款如何發揮作用",
|
||||
"pageHowBody": "Agora 是非託管的:你的 Bitcoin 會從你的錢包直接送到救援活動的位址。沒有平台從中抽成,沒有託管方握住資金,給予或接受都不需要任何人的許可。以下每一項活動,都是由在當地一線回應這場地震的人們所發起。",
|
||||
"goalOf": "目標 {{amount}}"
|
||||
"showcaseTitle": "救援活動",
|
||||
"campaignCount_one": "{{count}} 個救援活動",
|
||||
"campaignCount_other": "{{count}} 個救援活動"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "為什麼選擇 {{appName}}",
|
||||
|
||||
+3
-2
@@ -929,7 +929,6 @@
|
||||
"title": "委内瑞拉需要<0>你</0>",
|
||||
"body": "一场强烈地震袭击了委内瑞拉北部,Caracas 与 La Guaira 受灾最为严重。那里的家庭无法独自挺过这一切。你的捐款将直接送达灾区一线的救援活动,把你的善意化作今天的栖身之所、食物与希望。",
|
||||
"donate": "为救援捐款",
|
||||
"startCampaign": "为委内瑞拉筹款",
|
||||
"credit": "图片:居民在 Caracas 一栋倒塌建筑附近相拥。(AFP via Getty Images)",
|
||||
"share": "分享",
|
||||
"shareTitle": "在 Agora 上支援委内瑞拉地震救援",
|
||||
@@ -941,7 +940,9 @@
|
||||
"pageEyebrow": "紧急呼吁",
|
||||
"pageHow": "你的捐款如何帮助灾区",
|
||||
"pageHowBody": "Agora 是非托管的:你的 Bitcoin 从你的钱包直接送达救援活动的地址。没有平台抽成,没有托管方掌控资金,无论是给予还是接收都无需任何许可。下方的每一个活动都由在灾区一线响应地震的人们运作。",
|
||||
"goalOf": "目标 {{amount}}"
|
||||
"showcaseTitle": "救援活动",
|
||||
"campaignCount_one": "{{count}} 个救援活动",
|
||||
"campaignCount_other": "{{count}} 个救援活动"
|
||||
},
|
||||
"whyDifferent": {
|
||||
"eyebrow": "为什么选 {{appName}}",
|
||||
|
||||
@@ -1,42 +1,35 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import { HeartHandshake, Share2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HeroBanner } from '@/components/HeroBanner';
|
||||
import { VenezuelaReliefGoal } from '@/components/VenezuelaReliefGoal';
|
||||
import { CampaignDetailPage } from '@/pages/CampaignDetailPage';
|
||||
import { VenezuelaReliefShowcase } from '@/components/VenezuelaReliefShowcase';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { shareOrCopy } from '@/lib/share';
|
||||
import {
|
||||
VENEZUELA_RELIEF_CAMPAIGN_IDENTIFIER,
|
||||
VENEZUELA_RELIEF_CAMPAIGN_PUBKEY,
|
||||
VENEZUELA_RELIEF_IMAGES,
|
||||
VENEZUELA_RELIEF_PATH,
|
||||
} from '@/lib/venezuelaRelief';
|
||||
|
||||
/** DOM id of the showcase section the hero "Donate" CTA scrolls to. */
|
||||
const SHOWCASE_ID = 'venezuela-relief-campaigns';
|
||||
|
||||
/**
|
||||
* Dedicated, shareable Venezuela earthquake relief page (`/venezuela-relief`).
|
||||
*
|
||||
* The loud appeal hero (headline, body, live goal progress, donate /
|
||||
* fundraise / share CTAs) sits on top, sourced from the shared
|
||||
* `campaigns.home.venezuelaRelief.*` locale keys. Beneath it, the baked-in
|
||||
* relief campaign (`terremoto-venezuela`, kind 33863) is embedded in full
|
||||
* via {@link CampaignDetailPage} — the same story, donate panel, ledger,
|
||||
* and comments a donor sees at the campaign's naddr — so this URL is a
|
||||
* self-contained info + donation page that can be shared directly (social
|
||||
* posts, messages, QR).
|
||||
* The loud appeal hero (headline, body, live aggregate raised total,
|
||||
* donate / share CTAs) sits on top, sourced from the shared
|
||||
* `campaigns.home.venezuelaRelief.*` locale keys. Beneath it, a showcase
|
||||
* rail ({@link VenezuelaReliefShowcase}) lists every Venezuela-located
|
||||
* campaign tagged for relief (`humanitarian-aid` / `emergency-relief`),
|
||||
* resolved live — so this URL is a self-contained, shareable directory of
|
||||
* on-the-ground relief efforts a donor can give to directly.
|
||||
*
|
||||
* Routed under the wide FundraiserLayout so the hero spans the viewport
|
||||
* like /about. Remove the route in AppRouter when the relief response
|
||||
* winds down.
|
||||
*
|
||||
* Note: the embedded {@link CampaignDetailPage} sets its own SEO meta from
|
||||
* the campaign event, so it intentionally wins over the appeal copy here —
|
||||
* shared links surface the live campaign's title and cover.
|
||||
* Remove the route in AppRouter when the relief response winds down.
|
||||
*/
|
||||
export function VenezuelaReliefPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -44,10 +37,6 @@ export function VenezuelaReliefPage() {
|
||||
const shareOrigin = useShareOrigin();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Timer for clearing the transient donate-panel highlight (see
|
||||
// handleScrollToCampaign).
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
useSeoMeta({
|
||||
title: `${t('campaigns.home.venezuelaRelief.seoTitle')} | ${config.appName}`,
|
||||
description: t('campaigns.home.venezuelaRelief.seoDescription'),
|
||||
@@ -64,36 +53,12 @@ export function VenezuelaReliefPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// "Donate to relief" scrolls down to the embedded campaign rather than
|
||||
// navigating away — this page *is* the campaign. The donate panel (QR +
|
||||
// pay buttons) is rendered twice inside CampaignDetailPage: an inline
|
||||
// card at the top of the body on mobile (`#campaign-donate`) and a
|
||||
// sticky sidebar on desktop (`#campaign-donate-desktop`). We scroll to
|
||||
// and flash whichever one is actually laid out, so the real donate
|
||||
// controls come into focus on both breakpoints.
|
||||
//
|
||||
// The donate panel lives inside the embedded CampaignDetailPage, so we
|
||||
// can't drive its highlight through this component's React state; we
|
||||
// toggle a utility class on the DOM node directly instead. The ring
|
||||
// classes (and their reduced-motion fallback) live in index.css under
|
||||
// `.relief-donate-flash`.
|
||||
const handleScrollToCampaign = () => {
|
||||
const isVisible = (el: HTMLElement | null) => !!el && el.getClientRects().length > 0;
|
||||
const mobile = document.getElementById('campaign-donate');
|
||||
const desktop = document.getElementById('campaign-donate-desktop');
|
||||
const target =
|
||||
(isVisible(mobile) && mobile) ||
|
||||
(isVisible(desktop) && desktop) ||
|
||||
document.getElementById('venezuela-relief-campaign');
|
||||
if (!target) return;
|
||||
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
if (highlightTimer.current) clearTimeout(highlightTimer.current);
|
||||
target.classList.add('relief-donate-flash');
|
||||
highlightTimer.current = setTimeout(() => {
|
||||
target.classList.remove('relief-donate-flash');
|
||||
}, 2000);
|
||||
// "Donate to relief" scrolls down to the campaign showcase rather than
|
||||
// navigating away — this page *is* the directory of relief campaigns.
|
||||
const handleScrollToCampaigns = () => {
|
||||
document
|
||||
.getElementById(SHOWCASE_ID)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -139,13 +104,13 @@ export function VenezuelaReliefPage() {
|
||||
{t('campaigns.home.venezuelaRelief.body')}
|
||||
</p>
|
||||
|
||||
{/* Live fundraising progress for the baked-in relief campaign. */}
|
||||
{/* Live aggregate raised total across all matching campaigns. */}
|
||||
<VenezuelaReliefGoal variant="overlay" className="mt-7" />
|
||||
|
||||
<div className="mt-7 flex flex-col sm:flex-row flex-wrap gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleScrollToCampaign}
|
||||
onClick={handleScrollToCampaigns}
|
||||
className="rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px] motion-safe:transition-colors"
|
||||
>
|
||||
<HeartHandshake className="mr-2" />
|
||||
@@ -170,17 +135,19 @@ export function VenezuelaReliefPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* The actual relief campaign, baked in: story, donate panel,
|
||||
ledger, and comments — the full detail UI for the campaign at
|
||||
VENEZUELA_DONATE_PATH. The "Donate to relief" CTA flashes a
|
||||
highlight ring on the donate panel (mobile) or this section
|
||||
(desktop) via the `.relief-donate-flash` class — see
|
||||
handleScrollToCampaign. */}
|
||||
<div id="venezuela-relief-campaign" className="scroll-mt-4 rounded-2xl">
|
||||
<CampaignDetailPage
|
||||
pubkey={VENEZUELA_RELIEF_CAMPAIGN_PUBKEY}
|
||||
identifier={VENEZUELA_RELIEF_CAMPAIGN_IDENTIFIER}
|
||||
/>
|
||||
{/* Body: how-it-works explainer + the live showcase of every matching
|
||||
Venezuela relief campaign. */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-12 lg:py-16 space-y-12">
|
||||
<section className="max-w-3xl">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
|
||||
{t('campaigns.home.venezuelaRelief.pageHow')}
|
||||
</h2>
|
||||
<p className="mt-4 text-base sm:text-lg text-muted-foreground leading-relaxed">
|
||||
{t('campaigns.home.venezuelaRelief.pageHowBody')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<VenezuelaReliefShowcase id={SHOWCASE_ID} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user