Turn the home page into a featured-only launchpad for all three surfaces

The home page used to be the canonical browse view for campaigns: hero
plus featured row, then the full community grid, then moderator-only
Pending/Hidden sections, then a per-viewer 'Your campaigns' shelf.
With /campaigns/all, /groups, and /pledges all now hosting their own
dedicated browse views (featured + search + sort + country in one
unified section), the home page no longer needs to duplicate the
campaigns browse experience.

Rebuild / as a three-section launchpad:

  Hero  -> unchanged (HeroLightningMap, Bebas Neue tagline, brand CTAs).
  Featured campaigns  -> capped at 4, links to /campaigns/all.
  Featured groups     -> capped at 8, links to /groups.
  Featured pledges    -> capped at 8, links to /pledges.

Each section pulls its featured set from the same moderation labels
that drive the dedicated page, so what surfaces here matches what
surfaces there — just truncated. Sections with no featured items
collapse silently (no empty card) so the page degrades gracefully if
moderators only curate one or two surfaces.

Each section's skeleton respects the dependency chain that gates its
underlying query: campaigns wait on useCampaignModeration, groups on
useOrganizationModeration (because useFeaturedOrganizations is
internally gated on it), pledges on usePledgeModeration. While those
are still resolving the section renders skeleton cards rather than
flashing an empty state.

Drop the unmoderated community grid, the Pending/Hidden moderator
sections, the 'Your campaigns' shelf, and the campaign-search
toolbar from the home page. All of that now lives on /campaigns/all
where viewers actually expect to browse and filter.

Add browseAllGroups and browseAllPledges to campaigns.home in all
16 locales so each section can link out with locale-appropriate copy.
This commit is contained in:
lemon
2026-05-28 13:47:02 -07:00
parent 83554c726d
commit 7ccff2fbad
17 changed files with 446 additions and 352 deletions
+2
View File
@@ -692,6 +692,8 @@
"community": "حملات المجتمع",
"communityDesc": "ساعد في تمويل التغييرات التي تستحق العناء.",
"browseAll": "تصفّح كل الحملات ←",
"browseAllGroups": "تصفّح كل المجموعات ←",
"browseAllPledges": "تصفّح كل التعهدات ←",
"pending": "بانتظار الموافقة",
"pendingDesc": "حملات موجودة على الشبكة لم يوافق عليها ولم يُخفها أي مشرف من فريق Soapbox بعد.",
"pendingEmpty": "لا يوجد شيء بانتظار المراجعة.",
+2
View File
@@ -1128,6 +1128,8 @@
"community": "Community Campaigns",
"communityDesc": "Help fund the changes worth making.",
"browseAll": "Browse all campaigns →",
"browseAllGroups": "Browse all groups →",
"browseAllPledges": "Browse all pledges →",
"searchPlaceholder": "Search campaigns\u2026",
"searchAriaLabel": "Search campaigns",
"noMatch": "No campaigns match \u201c{{query}}\u201d",
+2
View File
@@ -704,6 +704,8 @@
"community": "Campañas de la comunidad",
"communityDesc": "Ayuda a financiar los cambios que valen la pena.",
"browseAll": "Ver todas las campañas →",
"browseAllGroups": "Ver todos los grupos →",
"browseAllPledges": "Ver todas las promesas →",
"pending": "Pendientes de aprobación",
"pendingDesc": "Campañas presentes en la red que ningún moderador del equipo Soapbox ha aprobado u ocultado todavía.",
"pendingEmpty": "Nada pendiente de revisión.",
+2
View File
@@ -704,6 +704,8 @@
"community": "کمپین‌های جامعه",
"communityDesc": "به تأمین مالی تغییراتی که ارزش انجام دادن دارند کمک کنید.",
"browseAll": "← مرور همه کمپین‌ها",
"browseAllGroups": "← مرور همه گروه‌ها",
"browseAllPledges": "← مرور همه تعهدها",
"pending": "در انتظار تأیید",
"pendingDesc": "کمپین‌هایی که در شبکه هستند و هیچ ناظر تیم Soapbox هنوز آن‌ها را تأیید یا پنهان نکرده است.",
"pendingEmpty": "چیزی برای بررسی نیست.",
+2
View File
@@ -1126,6 +1126,8 @@
"community": "Campagnes communautaires",
"communityDesc": "Aidez à financer les changements qui valent la peine d'être menés.",
"browseAll": "Parcourir toutes les campagnes →",
"browseAllGroups": "Parcourir tous les groupes →",
"browseAllPledges": "Parcourir toutes les promesses →",
"pending": "En attente d'approbation",
"pendingDesc": "Campagnes sur le réseau qu'aucun modérateur de Team Soapbox n'a encore approuvées ou masquées.",
"pendingEmpty": "Rien en attente d'examen.",
+2
View File
@@ -1136,6 +1136,8 @@
"community": "कम्युनिटी कैंपेन",
"communityDesc": "उन बदलावों को फंड करने में मदद करें जिनके होने लायक़ हैं।",
"browseAll": "सभी कैंपेन देखें →",
"browseAllGroups": "सभी ग्रुप देखें →",
"browseAllPledges": "सभी प्लेज देखें →",
"pending": "मंज़ूरी का इंतज़ार",
"pendingDesc": "नेटवर्क पर ऐसे कैंपेन जिन्हें Team Soapbox के किसी मॉडरेटर ने अभी मंज़ूरी या छुपाया नहीं है।",
"pendingEmpty": "समीक्षा का इंतज़ार में कुछ नहीं।",
+2
View File
@@ -1136,6 +1136,8 @@
"community": "Kampanye Komunitas",
"communityDesc": "Bantu danai perubahan yang patut dilakukan.",
"browseAll": "Telusuri semua kampanye →",
"browseAllGroups": "Telusuri semua grup →",
"browseAllPledges": "Telusuri semua ikrar →",
"pending": "Menunggu persetujuan",
"pendingDesc": "Kampanye di jaringan yang belum disetujui atau disembunyikan oleh moderator Team Soapbox.",
"pendingEmpty": "Tidak ada yang menunggu peninjauan.",
+2
View File
@@ -704,6 +704,8 @@
"community": "យុទ្ធនាការសហគមន៍",
"communityDesc": "ជួយផ្ដល់មូលនិធិដល់ការផ្លាស់ប្ដូរដែលសក្តិសម។",
"browseAll": "មើលយុទ្ធនាការទាំងអស់ →",
"browseAllGroups": "មើលក្រុមទាំងអស់ →",
"browseAllPledges": "មើលការសន្យាទាំងអស់ →",
"pending": "កំពុងរង់ចាំការអនុម័ត",
"pendingDesc": "យុទ្ធនាការនៅលើបណ្ដាញដែលគ្មានអ្នកសម្របសម្រួលក្រុម Soapbox អនុម័ត ឬលាក់នៅឡើយ។",
"pendingEmpty": "គ្មានអ្វីរង់ចាំការពិនិត្យ។",
+2
View File
@@ -704,6 +704,8 @@
"community": "د ټولنې کمپاینونه",
"communityDesc": "د هغو بدلونونو په تمویل کې مرسته وکړئ چې وړ دي.",
"browseAll": "← ټول کمپاینونه وګورئ",
"browseAllGroups": "← ټولې ډلې وګورئ",
"browseAllPledges": "← ټولې ژمنې وګورئ",
"pending": "د منلو په تمه",
"pendingDesc": "هغه کمپاینونه چې په شبکه کې شته، خو د Soapbox ټیم هیڅ مدیر یې لا تر اوسه نه دی منلی او نه پټ کړی.",
"pendingEmpty": "د بیاکتنې لپاره څه نشته.",
+2
View File
@@ -1136,6 +1136,8 @@
"community": "Campanhas da comunidade",
"communityDesc": "Ajude a financiar as mudanças que valem a pena.",
"browseAll": "Navegar por todas as campanhas →",
"browseAllGroups": "Navegar por todos os grupos →",
"browseAllPledges": "Navegar por todas as promessas →",
"pending": "Aguardando aprovação",
"pendingDesc": "Campanhas na rede que nenhum moderador da Team Soapbox aprovou ou ocultou ainda.",
"pendingEmpty": "Nada aguardando revisão.",
+2
View File
@@ -1136,6 +1136,8 @@
"community": "Кампании сообщества",
"communityDesc": "Помогите финансировать изменения, которые стоит сделать.",
"browseAll": "Просмотреть все кампании →",
"browseAllGroups": "Просмотреть все группы →",
"browseAllPledges": "Просмотреть все обещания →",
"pending": "Ожидают одобрения",
"pendingDesc": "Кампании в сети, которые ещё не были одобрены или скрыты модератором Team Soapbox.",
"pendingEmpty": "Ничего не ждёт проверки.",
+2
View File
@@ -704,6 +704,8 @@
"community": "Mishandirapamwe yeNharaunda",
"communityDesc": "Batsira kupa mari kushanduko dzakakodzera.",
"browseAll": "Tarisa mishandirapamwe yose →",
"browseAllGroups": "Tarisa mapoka ose →",
"browseAllPledges": "Tarisa zvitsidziro zvose →",
"pending": "Yakamirira kutenderwa",
"pendingDesc": "Mishandirapamwe iri panetwork isati yatenderwa kana kuvanzwa naani zvake muTeam Soapbox.",
"pendingEmpty": "Hapana chinomirira kutariswa.",
+2
View File
@@ -1135,6 +1135,8 @@
"community": "Kampeni za Jumuiya",
"communityDesc": "Saidia kufadhili mabadiliko yanayostahili kufanywa.",
"browseAll": "Vinjari kampeni zote →",
"browseAllGroups": "Vinjari vikundi vyote →",
"browseAllPledges": "Vinjari ahadi zote →",
"pending": "Inasubiri idhini",
"pendingDesc": "Kampeni kwenye mtandao ambazo hakuna msimamizi wa Team Soapbox aliyezithibitisha au kuzificha bado.",
"pendingEmpty": "Hakuna kinachosubiri ukaguzi.",
+2
View File
@@ -1135,6 +1135,8 @@
"community": "Topluluk Kampanyaları",
"communityDesc": "Yapmaya değer değişiklikleri finanse etmeye yardım edin.",
"browseAll": "Tüm kampanyalara göz at →",
"browseAllGroups": "Tüm gruplara göz at →",
"browseAllPledges": "Tüm taahhütlere göz at →",
"pending": "Onay bekliyor",
"pendingDesc": "Ağdaki, henüz hiçbir Team Soapbox moderatörünün onaylamadığı veya gizlemediği kampanyalar.",
"pendingEmpty": "İncelemeyi bekleyen bir şey yok.",
+2
View File
@@ -704,6 +704,8 @@
"community": "社群活動",
"communityDesc": "為值得做的改變提供資金。",
"browseAll": "瀏覽所有活動 →",
"browseAllGroups": "瀏覽所有群組 →",
"browseAllPledges": "瀏覽所有懸賞 →",
"pending": "等待審批",
"pendingDesc": "網路上尚未被任何 Soapbox 團隊版主批准或隱藏的活動。",
"pendingEmpty": "沒有等待審查的內容。",
+2
View File
@@ -704,6 +704,8 @@
"community": "社区活动",
"communityDesc": "为值得做的改变提供资金。",
"browseAll": "浏览所有活动 →",
"browseAllGroups": "浏览所有群组 →",
"browseAllPledges": "浏览所有悬赏 →",
"pending": "等待审批",
"pendingDesc": "网络上尚未被任何 Soapbox 团队版主批准或隐藏的活动。",
"pendingEmpty": "没有等待审查的内容。",
+414 -352
View File
@@ -2,50 +2,262 @@ import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { Trans, useTranslation } from 'react-i18next';
import { ArrowRight, EyeOff, HandHeart, Hourglass, PlusCircle, ShieldCheck } from 'lucide-react';
import { ArrowRight, PlusCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import {
CommunityMiniCard,
CommunityMiniCardSkeleton,
} from '@/components/discovery/CommunityMiniCard';
import { CommunityGrid } from '@/components/discovery/CommunityGrid';
import { HeroLightningMap } from '@/components/HeroLightningMap';
import { ModeratorCollapsibleSection } from '@/components/moderation';
import { PledgeCard } from '@/components/PledgeCard';
import { useActions, type Action } from '@/hooks/useActions';
import { useAppContext } from '@/hooks/useAppContext';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
import type { ParsedCampaign } from '@/lib/campaign';
import type { ParsedCommunity } from '@/lib/communityUtils';
/** Cap on how many featured campaigns we render in the home-page row. */
const MAX_FEATURED = 4;
const MAX_FEATURED_CAMPAIGNS = 4;
/**
* Cap on featured groups and featured pledges in their respective home-page
* sections. The dedicated pages render unlimited featured items; the home
* page is a launchpad, not the canonical list.
*/
const MAX_FEATURED_PER_SECTION = 8;
function getPledgeCoord(action: Action) {
return `36639:${action.pubkey}:${action.id}`;
}
/**
* Home page (`/`).
*
* A curated launchpad: hero on top, then three featured sections — campaigns,
* groups, pledges — each capped to a digestible row and linking out to its
* dedicated browse page (`/campaigns/all`, `/groups`, `/pledges`). The home
* page intentionally does *not* show community/pending/hidden grids,
* unmoderated streams, or per-viewer "your X" shelves — those live on the
* dedicated pages so the home stays scannable on every visit.
*
* Each section's "featured" set is derived from the same moderation labels
* used on its dedicated page, so what surfaces here matches what surfaces
* there (just truncated). Sections with no featured items collapse silently
* rather than render an empty card; the page can degrade to "hero + one
* section" without looking broken.
*/
export function CampaignsPage() {
const { t } = useTranslation();
const { config } = useAppContext();
const { user } = useCurrentUser();
// Moderator pack + per-campaign label state. The label query is gated on
// moderators arriving, so during a cold load we render skeleton cards
// until both resolve. Avoids flashing the full unmoderated grid.
const { data: moderators, isLoading: moderatorsLoading } = useCampaignModerators();
useSeoMeta({
title: `${t('campaigns.home.seoTitle')} | ${config.appName}`,
description: t('campaigns.home.seoDescription'),
});
return (
<main className="min-h-screen pb-16">
<Hero loggedIn={!!user} />
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12" id="featured">
<FeaturedCampaignsSection />
<FeaturedGroupsSection />
<FeaturedPledgesSection />
</div>
</main>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Hero
// ═══════════════════════════════════════════════════════════════════════════════
function Hero({ loggedIn }: { loggedIn: boolean }) {
const { t } = useTranslation();
return (
/* Hero.
Dark, brand-driven, type-led. Three layers:
1. Near-black backdrop (`bg-[hsl(220_25%_6%)]`) — the canvas
every other element sits on. No campaign photo, no random
hue cycling: the hero looks the same on every visit, so
quality doesn't depend on which campaign is featured.
2. HeroLightningMap — decorative dark world map with curated
glowing brand-orange arcs and pulsing city nodes. Pure SVG,
negligible render cost, animations honor reduced-motion.
3. Headline column on the left, lifted by a left-edge gradient
inside HeroLightningMap so type stays readable without any
text-shadow at all. */
<section className="relative overflow-hidden border-b border-border bg-[hsl(220_25%_6%)] text-white">
<HeroLightningMap />
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-16 lg:py-24 min-h-[440px] sm:min-h-[480px] lg:min-h-[520px] flex flex-col justify-center">
<div className="space-y-6 max-w-2xl">
<h1
className="font-display italic text-6xl sm:text-7xl lg:text-8xl font-normal tracking-wide leading-none uppercase"
style={{
// Bebas Neue only ships at weight 400. Paint a stroke the
// same color as the fill to fatten the letterforms without
// the fuzz a synthetic-bold transform would produce.
WebkitTextStroke: '0.022em currentColor',
}}
>
<Trans
i18nKey="campaigns.home.heroTagline"
components={[
// Index 0: solid brand-orange highlighter block.
// i18next injects the matched translation segment
// (the text between <0>...</0>) as this span's
// `children`, so the word renders *inside* the
// orange background.
//
// Padding: `ps-0 pe-3` keeps the first letter flush
// with the box's start edge while the box extends
// past the trailing edge as a deliberate visual
// flourish. Logical properties so RTL languages
// (ar, fa, ps) flip automatically.
//
// `text-indent: -0.06em` compensates for Bebas
// Neue's italic skew, which shifts the visible
// left edge of "U" rightward of its geometric box
// — without the nudge there's a visible gap
// between the orange block's left edge and the
// letter. The shift is small enough that other
// scripts (Arabic, Khmer, Chinese) tolerate it.
//
// NOTE: `components` MUST be an array, not an
// object keyed by `{0: ..., 1: ...}`. The object
// form silently drops the indexed tags in this
// react-i18next version, rendering the text
// without any wrapping element.
<span
key="hl"
className="inline-block w-fit ps-0 pe-3 bg-primary text-white leading-[0.95] align-baseline"
style={{ textIndent: '-0.06em' }}
/>,
// Index 1: line break. English wants the
// highlighted word on its own line as a standalone
// block. Translations that prefer inline flow
// simply omit `<1></1>` from their string.
<br key="br" />,
]}
/>
</h1>
<p className="text-base sm:text-lg text-white/80 max-w-xl">
{t('campaigns.home.heroBody')}
</p>
<div className="flex flex-wrap gap-3 pt-1">
{/* Primary CTA — solid brand-orange pill. The dark hero gives
the brand color the spotlight without competing with it. */}
<Button
size="lg"
asChild
className="rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px] motion-safe:transition-colors"
>
<Link to="/campaigns/new">
<PlusCircle className="mr-2" />
{t('campaigns.home.startCampaign')}
</Link>
</Button>
<Button
variant="outline"
size="lg"
asChild
className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50 [&_svg]:size-[18px]"
>
<Link to="/about">
{t('campaigns.home.howItWorks')}
<ArrowRight className="ml-2 rtl:rotate-180" />
</Link>
</Button>
{!loggedIn && (
<Button
variant="outline"
size="lg"
asChild
className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50"
>
<a href="#featured">{t('campaigns.home.exploreCampaigns')}</a>
</Button>
)}
</div>
</div>
</div>
</section>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Section header
// ═══════════════════════════════════════════════════════════════════════════════
function SectionHeader({
title,
description,
browseLabel,
browseHref,
}: {
title: string;
description: string;
browseLabel: string;
browseHref: string;
}) {
return (
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{title}</h2>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
</div>
<Button asChild variant="ghost" size="sm" className="shrink-0">
<Link to={browseHref}>
{browseLabel}
<ArrowRight className="size-4 ms-1 rtl:rotate-180" />
</Link>
</Button>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Featured Campaigns
// ═══════════════════════════════════════════════════════════════════════════════
function FeaturedCampaignsSection() {
const { t } = useTranslation();
const { config } = useAppContext();
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
// Featured slot list — derived from moderation labels. Sorted newest-
// featured first, capped at MAX_FEATURED, and hidden coords removed so a
// featured first, capped, and hidden coords removed so a
// featured-then-hidden campaign disappears from the row.
const featuredCoords = useMemo(() => {
if (!moderation) return [] as string[];
return Array.from(moderation.featuredCoords)
.filter((c) => !moderation.hiddenCoords.has(c))
.sort((a, b) => (moderation.featuredOrder.get(b) ?? 0) - (moderation.featuredOrder.get(a) ?? 0))
.slice(0, MAX_FEATURED);
.sort(
(a, b) =>
(moderation.featuredOrder.get(b) ?? 0) -
(moderation.featuredOrder.get(a) ?? 0),
)
.slice(0, MAX_FEATURED_CAMPAIGNS);
}, [moderation]);
const { data: featuredCampaigns, isLoading: featuredLoading } = useCampaigns(
moderationReady && featuredCoords.length > 0
? { coordinates: featuredCoords, limit: MAX_FEATURED }
: { coordinates: [], limit: MAX_FEATURED },
? { coordinates: featuredCoords, limit: MAX_FEATURED_CAMPAIGNS }
: { coordinates: [], limit: MAX_FEATURED_CAMPAIGNS },
);
// Sort the fetched featured campaigns to match the newest-label order.
@@ -57,322 +269,31 @@ export function CampaignsPage() {
return [...featuredCampaigns]
.filter((c) => featuredCoords.includes(c.aTag))
.sort((a, b) => (order.get(b.aTag) ?? 0) - (order.get(a.aTag) ?? 0))
.slice(0, MAX_FEATURED);
.slice(0, MAX_FEATURED_CAMPAIGNS);
}, [featuredCampaigns, featuredCoords, moderation]);
const featuredCoordSet = useMemo(() => new Set(featuredCoords), [featuredCoords]);
const isLoading = !moderationReady || featuredLoading;
// The community grid is the approved-and-not-hidden set, minus featured
// (which gets its own row above). We fetch by coordinate (one filter per
// author, bundled in one REQ) to avoid pulling the entire kind-30223
// stream when only a handful are surfaced.
const approvedNotHidden = useMemo(() => {
if (!moderation) return [] as string[];
return Array.from(moderation.approvedCoords).filter((c) => !moderation.hiddenCoords.has(c));
}, [moderation]);
// Pass `coordinates: []` only once moderation is ready and the allowlist is
// empty; before that, pass `undefined` so the query is enabled but doesn't
// discriminate. We block render of the grid on `moderationReady` anyway.
const { data: approvedCampaigns, isLoading: approvedLoading } = useCampaigns(
moderationReady
? { coordinates: approvedNotHidden, limit: 60 }
: { limit: 60 },
);
// For moderators we also pull the *entire* recent kind-30223 stream so we
// can populate the Pending and Hidden sections. This second query only
// runs for mods and reuses TanStack's cache on identical keys.
const { data: allCampaignsForMods, isLoading: allLoading } = useCampaigns({
limit: 200,
});
// For non-mod creators: their own campaigns regardless of moderation state,
// so the "Your campaigns" shelf can explain why theirs aren't on the home
// page. Skip the query entirely for mods and logged-out viewers.
const { data: ownCampaigns } = useCampaigns({
authors: user && !isMod ? [user.pubkey] : undefined,
limit: 30,
});
useSeoMeta({
title: `${t('campaigns.home.seoTitle')} | ${config.appName}`,
description: t('campaigns.home.seoDescription'),
});
// Main grid excludes featured (they're shown above) and excludes any
// hidden coord just in case approvedCoords/hiddenCoords overlap (a mod can
// approve, another can hide — hide wins).
const mainGridCampaigns = useMemo(
() =>
(approvedCampaigns ?? []).filter(
(c) => !featuredCoordSet.has(c.aTag) && !moderation?.hiddenCoords.has(c.aTag),
),
[approvedCampaigns, featuredCoordSet, moderation],
);
// Pending (mod-only): campaigns that exist on the network but lack an
// approval AND aren't hidden.
const pendingCampaigns = useMemo(() => {
if (!isMod || !moderation) return [] as ParsedCampaign[];
return (allCampaignsForMods ?? []).filter(
(c) => !moderation.approvedCoords.has(c.aTag) && !moderation.hiddenCoords.has(c.aTag),
);
}, [isMod, moderation, allCampaignsForMods]);
// Hidden (mod-only): campaigns where the latest hide-axis label is `hidden`.
const hiddenCampaigns = useMemo(() => {
if (!isMod || !moderation) return [] as ParsedCampaign[];
return (allCampaignsForMods ?? []).filter((c) => moderation.hiddenCoords.has(c.aTag));
}, [isMod, moderation, allCampaignsForMods]);
// "Your campaigns" (non-mod creators only): the logged-in user's own
// campaigns that aren't yet surfaced — i.e. not approved, or hidden.
// We exclude already-approved ones so we don't double-render the same
// card in two sections; if their own campaign is in the main grid they
// already know it's live.
const yourPendingCampaigns = useMemo(() => {
if (isMod || !user || !moderation) return [] as ParsedCampaign[];
return (ownCampaigns ?? []).filter(
(c) => !moderation.approvedCoords.has(c.aTag) || moderation.hiddenCoords.has(c.aTag),
);
}, [isMod, user, moderation, ownCampaigns]);
// Once moderation is ready and there's nothing featured, collapse the
// section silently — the home page can degrade to "hero + the sections
// that have content".
if (moderationReady && featuredCoords.length === 0) return null;
return (
<main className="min-h-screen pb-16">
{/* Hero.
<section className="space-y-5">
<SectionHeader
title={t('campaigns.home.featured')}
description={t('campaigns.home.featuredDesc', { appName: config.appName })}
browseLabel={t('campaigns.home.browseAll')}
browseHref="/campaigns/all"
/>
Dark, brand-driven, type-led. Three layers:
1. Near-black backdrop (`bg-[hsl(220_25%_6%)]`) — the canvas
every other element sits on. No campaign photo, no random
hue cycling: the hero looks the same on every visit, so
quality doesn't depend on which campaign is featured.
2. HeroLightningMap — decorative dark world map with curated
glowing brand-orange arcs and pulsing city nodes. Pure SVG,
negligible render cost, animations honor reduced-motion.
3. Headline column on the left, lifted by a left-edge gradient
inside HeroLightningMap so type stays readable without any
text-shadow at all. */}
<section className="relative overflow-hidden border-b border-border bg-[hsl(220_25%_6%)] text-white">
<HeroLightningMap />
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-16 lg:py-24 min-h-[440px] sm:min-h-[480px] lg:min-h-[520px] flex flex-col justify-center">
<div className="space-y-6 max-w-2xl">
<h1
className="font-display italic text-6xl sm:text-7xl lg:text-8xl font-normal tracking-wide leading-none uppercase"
style={{
// Bebas Neue only ships at weight 400. Paint a stroke the
// same color as the fill to fatten the letterforms without
// the fuzz a synthetic-bold transform would produce.
WebkitTextStroke: '0.022em currentColor',
}}
>
<Trans
i18nKey="campaigns.home.heroTagline"
components={[
// Index 0: solid brand-orange highlighter block.
// i18next injects the matched translation segment
// (the text between <0>...</0>) as this span's
// `children`, so the word renders *inside* the
// orange background.
//
// Padding: `ps-0 pe-3` keeps the first letter flush
// with the box's start edge while the box extends
// past the trailing edge as a deliberate visual
// flourish. Logical properties so RTL languages
// (ar, fa, ps) flip automatically.
//
// `text-indent: -0.06em` compensates for Bebas
// Neue's italic skew, which shifts the visible
// left edge of "U" rightward of its geometric box
// — without the nudge there's a visible gap
// between the orange block's left edge and the
// letter. The shift is small enough that other
// scripts (Arabic, Khmer, Chinese) tolerate it.
//
// NOTE: `components` MUST be an array, not an
// object keyed by `{0: ..., 1: ...}`. The object
// form silently drops the indexed tags in this
// react-i18next version, rendering the text
// without any wrapping element.
<span
key="hl"
className="inline-block w-fit ps-0 pe-3 bg-primary text-white leading-[0.95] align-baseline"
style={{ textIndent: '-0.06em' }}
/>,
// Index 1: line break. English wants the
// highlighted word on its own line as a standalone
// block. Translations that prefer inline flow
// simply omit `<1></1>` from their string.
<br key="br" />,
]}
/>
</h1>
<p className="text-base sm:text-lg text-white/80 max-w-xl">
{t('campaigns.home.heroBody')}
</p>
<div className="flex flex-wrap gap-3 pt-1">
{/* Primary CTA — solid brand-orange pill. The dark hero gives
the brand color the spotlight without competing with it. */}
<Button
size="lg"
asChild
className="rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px] motion-safe:transition-colors"
>
<Link to="/campaigns/new">
<PlusCircle className="mr-2" />
{t('campaigns.home.startCampaign')}
</Link>
</Button>
<Button
variant="outline"
size="lg"
asChild
className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50 [&_svg]:size-[18px]"
>
<Link to="/about">
{t('campaigns.home.howItWorks')}
<ArrowRight className="ml-2 rtl:rotate-180" />
</Link>
</Button>
{!user && (
<Button
variant="outline"
size="lg"
asChild
className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50"
>
<a href="#campaigns">{t('campaigns.home.exploreCampaigns')}</a>
</Button>
)}
</div>
</div>
</div>
</section>
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12" id="campaigns">
{/* Featured — only rendered when at least one campaign is featured
(or the featured query is still loading on first paint). */}
{(featuredCoords.length > 0 || (featuredLoading && !moderationReady)) && (
<section className="space-y-5">
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{t('campaigns.home.featured')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('campaigns.home.featuredDesc', { appName: config.appName })}
</p>
</div>
</div>
<FeaturedRow
campaigns={orderedFeatured}
isLoading={featuredLoading || !moderationReady}
expectedCount={featuredCoords.length}
/>
</section>
)}
{/* Community Campaigns — approved-and-not-hidden, minus featured.
Skeletons until the moderator pack + label state both resolve,
so we never flash an unmoderated grid. */}
<section className="space-y-5">
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{t('campaigns.home.community')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('campaigns.home.communityDesc')}
</p>
</div>
<Button asChild variant="outline" className="hidden sm:inline-flex">
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-2" />
{t('campaigns.home.startCampaign')}
</Link>
</Button>
</div>
{moderatorsLoading || !moderationReady || approvedLoading ? (
<CampaignGridSkeleton />
) : mainGridCampaigns.length === 0 ? (
<EmptyState />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{mainGridCampaigns.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
)}
{/* "Browse all campaigns" link — reveals the page that includes
campaigns not yet moderated (and, optionally, hidden ones). */}
<div className="pt-2 text-center sm:text-left">
<Button asChild variant="ghost" size="sm">
<Link to="/campaigns/all">{t('campaigns.home.browseAll')}</Link>
</Button>
</div>
</section>
{/* Moderator-only: campaigns awaiting an approval decision. */}
{isMod && (
<ModeratorCollapsibleSection
icon={<Hourglass className="size-4" />}
title={t('campaigns.home.pending')}
description={t('campaigns.home.pendingDesc')}
count={pendingCampaigns.length}
isLoading={allLoading}
emptyText={t('campaigns.home.pendingEmpty')}
skeleton={<CampaignGridSkeleton />}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{pendingCampaigns.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
</ModeratorCollapsibleSection>
)}
{/* Moderator-only: campaigns currently hidden. */}
{isMod && (
<ModeratorCollapsibleSection
icon={<EyeOff className="size-4" />}
title={t('campaigns.home.hidden')}
description={t('campaigns.home.hiddenDesc')}
count={hiddenCampaigns.length}
isLoading={allLoading}
emptyText={t('campaigns.home.hiddenEmpty')}
skeleton={<CampaignGridSkeleton />}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{hiddenCampaigns.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
</ModeratorCollapsibleSection>
)}
{/* Non-mod creator: surface their own not-yet-approved campaigns
so they understand the campaign is live on the network but
isn't on the homepage yet. */}
{!isMod && user && yourPendingCampaigns.length > 0 && (
<section className="space-y-5">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight inline-flex items-center gap-2">
<ShieldCheck className="size-6 text-primary" />
{t('campaigns.home.yourCampaigns')}
</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">
{t('campaigns.home.yourCampaignsDesc')}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{yourPendingCampaigns.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
</section>
)}
</div>
</main>
<FeaturedCampaignsRow
campaigns={orderedFeatured}
isLoading={isLoading}
expectedCount={featuredCoords.length}
/>
</section>
);
}
@@ -389,8 +310,7 @@ function featuredGridClass(n: number): string {
return 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5';
}
/** Renders the featured row with an adaptive column count. */
function FeaturedRow({
function FeaturedCampaignsRow({
campaigns,
isLoading,
expectedCount,
@@ -401,11 +321,17 @@ function FeaturedRow({
expectedCount: number;
}) {
if (isLoading && campaigns.length === 0) {
const skeletonCount = Math.max(1, Math.min(MAX_FEATURED, expectedCount || 2));
const skeletonCount = Math.max(
1,
Math.min(MAX_FEATURED_CAMPAIGNS, expectedCount || 2),
);
return (
<div className={featuredGridClass(skeletonCount)}>
{Array.from({ length: skeletonCount }).map((_, i) => (
<CampaignCardSkeleton key={i} variant={skeletonCount === 1 ? 'featured' : 'compact'} />
<CampaignCardSkeleton
key={i}
variant={skeletonCount === 1 ? 'featured' : 'compact'}
/>
))}
</div>
);
@@ -435,38 +361,174 @@ function FeaturedRow({
);
}
function CampaignGridSkeleton() {
// ═══════════════════════════════════════════════════════════════════════════════
// Featured Groups
// ═══════════════════════════════════════════════════════════════════════════════
function FeaturedGroupsSection() {
const { t } = useTranslation();
const { data: orgModeration, isReady: orgModerationReady } =
useOrganizationModeration();
const { data: featuredOrgs, isLoading: featuredOrgsLoading } =
useFeaturedOrganizations();
const featuredGroups = useMemo<ParsedCommunity[]>(() => {
if (!featuredOrgs) return [];
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
return featuredOrgs
.map((entry) => entry.community)
.filter((c) => !hiddenCoords.has(c.aTag))
.slice(0, MAX_FEATURED_PER_SECTION);
}, [featuredOrgs, orgModeration]);
// `useFeaturedOrganizations` is internally gated on moderation readiness,
// so while moderation labels are still resolving the underlying query is
// disabled and reports `isLoading: false` / `data: undefined`. Treat any
// of "moderation not ready / featured query in flight / featured data
// not yet defined" as loading so the skeleton stays on screen until we
// know what's featured.
const isLoading =
!orgModerationReady || featuredOrgsLoading || featuredOrgs === undefined;
if (!isLoading && featuredGroups.length === 0) return null;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
<section className="space-y-5">
<SectionHeader
title={t('groups.list.featuredGroups')}
description={t('groups.list.featuredGroupsTagline')}
browseLabel={t('campaigns.home.browseAllGroups')}
browseHref="/groups"
/>
{isLoading ? (
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : (
<CommunityGrid>
{featuredGroups.map((community) => (
<CommunityMiniCard
key={community.aTag}
community={community}
className="w-full"
/>
))}
</CommunityGrid>
)}
</section>
);
}
function EmptyState() {
// ═══════════════════════════════════════════════════════════════════════════════
// Featured Pledges
// ═══════════════════════════════════════════════════════════════════════════════
function FeaturedPledgesSection() {
const { t } = useTranslation();
const { config } = useAppContext();
const { data: btcPrice } = useBtcPrice();
const { data: pledgeModeration, isReady: pledgeModerationReady } =
usePledgeModeration();
const featuredPledgeCoords = useMemo(() => {
if (!pledgeModerationReady) return [] as string[];
return Array.from(pledgeModeration.featuredCoords)
.filter((coord) => !pledgeModeration.hiddenCoords.has(coord))
.sort(
(a, b) =>
(pledgeModeration.featuredOrder.get(b) ?? 0) -
(pledgeModeration.featuredOrder.get(a) ?? 0),
)
.slice(0, MAX_FEATURED_PER_SECTION);
}, [pledgeModeration, pledgeModerationReady]);
const { data: featuredPledges, isLoading: featuredPledgesLoading } = useActions({
coordinates: featuredPledgeCoords,
limit: featuredPledgeCoords.length || 1,
enabled: pledgeModerationReady && featuredPledgeCoords.length > 0,
});
const orderedFeaturedPledges = useMemo<Action[]>(() => {
if (!featuredPledges || !pledgeModerationReady) return [];
const order = pledgeModeration.featuredOrder;
return [...featuredPledges]
.sort((a, b) => {
const aCoord = getPledgeCoord(a);
const bCoord = getPledgeCoord(b);
return (order.get(bCoord) ?? 0) - (order.get(aCoord) ?? 0);
})
.slice(0, MAX_FEATURED_PER_SECTION);
}, [featuredPledges, pledgeModeration, pledgeModerationReady]);
const isLoading =
!pledgeModerationReady ||
(featuredPledgeCoords.length > 0 && featuredPledgesLoading);
// Same silent-collapse rule as the other two sections: once we know
// there's nothing featured, drop the heading rather than render an
// empty container.
if (pledgeModerationReady && featuredPledgeCoords.length === 0) return null;
return (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-4">
<HandHeart className="size-10 text-muted-foreground mx-auto" />
<div className="space-y-1.5">
<h3 className="text-lg font-semibold">{t('campaigns.home.empty')}</h3>
<p className="text-muted-foreground max-w-sm mx-auto">
{t('campaigns.home.emptyHint', { appName: config.appName })}
</p>
<section className="space-y-5">
<SectionHeader
title={t('pledges.list.featuredPledges')}
description={t('pledges.list.featuredPledgesTagline', {
appName: config.appName,
})}
browseLabel={t('campaigns.home.browseAllPledges')}
browseHref="/pledges"
/>
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({
length: Math.max(
1,
Math.min(MAX_FEATURED_PER_SECTION, featuredPledgeCoords.length || 4),
),
}).map((_, i) => (
<PledgeSkeleton key={i} />
))}
</div>
<Button asChild>
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-2" />
{t('campaigns.home.startCampaign')}
</Link>
</Button>
</CardContent>
) : orderedFeaturedPledges.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{orderedFeaturedPledges.map((action) => (
<PledgeCard
key={`${action.pubkey}:${action.id}`}
action={action}
btcPrice={btcPrice}
showAuthor
showTranslate
/>
))}
</div>
) : (
// Defensive — featured coords resolved to a non-empty set but the
// events didn't come back (e.g. relay miss). Collapse silently.
null
)}
</section>
);
}
function PledgeSkeleton() {
return (
<Card className="overflow-hidden border-border/70 shadow-sm h-full flex flex-col">
<Skeleton className="aspect-[16/9] w-full rounded-none" />
<div className="flex-1 p-5 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-2 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</Card>
);
}
// Re-exported so AppRouter's lazy import shape stays identical.
export default CampaignsPage;