diff --git a/src/locales/ar.json b/src/locales/ar.json
index 9eeeebe2..1f003e25 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -692,6 +692,8 @@
"community": "حملات المجتمع",
"communityDesc": "ساعد في تمويل التغييرات التي تستحق العناء.",
"browseAll": "تصفّح كل الحملات ←",
+ "browseAllGroups": "تصفّح كل المجموعات ←",
+ "browseAllPledges": "تصفّح كل التعهدات ←",
"pending": "بانتظار الموافقة",
"pendingDesc": "حملات موجودة على الشبكة لم يوافق عليها ولم يُخفها أي مشرف من فريق Soapbox بعد.",
"pendingEmpty": "لا يوجد شيء بانتظار المراجعة.",
diff --git a/src/locales/en.json b/src/locales/en.json
index d2932e16..c8ba81d4 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -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",
diff --git a/src/locales/es.json b/src/locales/es.json
index 1966cf8a..cbf16b71 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -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.",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 9d12c314..8474f951 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -704,6 +704,8 @@
"community": "کمپینهای جامعه",
"communityDesc": "به تأمین مالی تغییراتی که ارزش انجام دادن دارند کمک کنید.",
"browseAll": "← مرور همه کمپینها",
+ "browseAllGroups": "← مرور همه گروهها",
+ "browseAllPledges": "← مرور همه تعهدها",
"pending": "در انتظار تأیید",
"pendingDesc": "کمپینهایی که در شبکه هستند و هیچ ناظر تیم Soapbox هنوز آنها را تأیید یا پنهان نکرده است.",
"pendingEmpty": "چیزی برای بررسی نیست.",
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 50b5c1fd..ad915014 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -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.",
diff --git a/src/locales/hi.json b/src/locales/hi.json
index 1877ec6e..9a75d7e9 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -1136,6 +1136,8 @@
"community": "कम्युनिटी कैंपेन",
"communityDesc": "उन बदलावों को फंड करने में मदद करें जिनके होने लायक़ हैं।",
"browseAll": "सभी कैंपेन देखें →",
+ "browseAllGroups": "सभी ग्रुप देखें →",
+ "browseAllPledges": "सभी प्लेज देखें →",
"pending": "मंज़ूरी का इंतज़ार",
"pendingDesc": "नेटवर्क पर ऐसे कैंपेन जिन्हें Team Soapbox के किसी मॉडरेटर ने अभी मंज़ूरी या छुपाया नहीं है।",
"pendingEmpty": "समीक्षा का इंतज़ार में कुछ नहीं।",
diff --git a/src/locales/id.json b/src/locales/id.json
index 2079bb2c..455b029f 100644
--- a/src/locales/id.json
+++ b/src/locales/id.json
@@ -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.",
diff --git a/src/locales/km.json b/src/locales/km.json
index 8ea9366d..bae9e204 100644
--- a/src/locales/km.json
+++ b/src/locales/km.json
@@ -704,6 +704,8 @@
"community": "យុទ្ធនាការសហគមន៍",
"communityDesc": "ជួយផ្ដល់មូលនិធិដល់ការផ្លាស់ប្ដូរដែលសក្តិសម។",
"browseAll": "មើលយុទ្ធនាការទាំងអស់ →",
+ "browseAllGroups": "មើលក្រុមទាំងអស់ →",
+ "browseAllPledges": "មើលការសន្យាទាំងអស់ →",
"pending": "កំពុងរង់ចាំការអនុម័ត",
"pendingDesc": "យុទ្ធនាការនៅលើបណ្ដាញដែលគ្មានអ្នកសម្របសម្រួលក្រុម Soapbox អនុម័ត ឬលាក់នៅឡើយ។",
"pendingEmpty": "គ្មានអ្វីរង់ចាំការពិនិត្យ។",
diff --git a/src/locales/ps.json b/src/locales/ps.json
index 3581a33f..cd60501a 100644
--- a/src/locales/ps.json
+++ b/src/locales/ps.json
@@ -704,6 +704,8 @@
"community": "د ټولنې کمپاینونه",
"communityDesc": "د هغو بدلونونو په تمویل کې مرسته وکړئ چې وړ دي.",
"browseAll": "← ټول کمپاینونه وګورئ",
+ "browseAllGroups": "← ټولې ډلې وګورئ",
+ "browseAllPledges": "← ټولې ژمنې وګورئ",
"pending": "د منلو په تمه",
"pendingDesc": "هغه کمپاینونه چې په شبکه کې شته، خو د Soapbox ټیم هیڅ مدیر یې لا تر اوسه نه دی منلی او نه پټ کړی.",
"pendingEmpty": "د بیاکتنې لپاره څه نشته.",
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 3d230be5..3ab30bbb 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -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.",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 03d3d1b4..395ea4d0 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -1136,6 +1136,8 @@
"community": "Кампании сообщества",
"communityDesc": "Помогите финансировать изменения, которые стоит сделать.",
"browseAll": "Просмотреть все кампании →",
+ "browseAllGroups": "Просмотреть все группы →",
+ "browseAllPledges": "Просмотреть все обещания →",
"pending": "Ожидают одобрения",
"pendingDesc": "Кампании в сети, которые ещё не были одобрены или скрыты модератором Team Soapbox.",
"pendingEmpty": "Ничего не ждёт проверки.",
diff --git a/src/locales/sn.json b/src/locales/sn.json
index bcfd48e5..08afb06c 100644
--- a/src/locales/sn.json
+++ b/src/locales/sn.json
@@ -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.",
diff --git a/src/locales/sw.json b/src/locales/sw.json
index 490cfa64..c87b6e37 100644
--- a/src/locales/sw.json
+++ b/src/locales/sw.json
@@ -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.",
diff --git a/src/locales/tr.json b/src/locales/tr.json
index ad4d573d..f8609cd2 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -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.",
diff --git a/src/locales/zh-Hant.json b/src/locales/zh-Hant.json
index 8d9e9d9c..58736221 100644
--- a/src/locales/zh-Hant.json
+++ b/src/locales/zh-Hant.json
@@ -704,6 +704,8 @@
"community": "社群活動",
"communityDesc": "為值得做的改變提供資金。",
"browseAll": "瀏覽所有活動 →",
+ "browseAllGroups": "瀏覽所有群組 →",
+ "browseAllPledges": "瀏覽所有懸賞 →",
"pending": "等待審批",
"pendingDesc": "網路上尚未被任何 Soapbox 團隊版主批准或隱藏的活動。",
"pendingEmpty": "沒有等待審查的內容。",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index db6b35c7..0783ca58 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -704,6 +704,8 @@
"community": "社区活动",
"communityDesc": "为值得做的改变提供资金。",
"browseAll": "浏览所有活动 →",
+ "browseAllGroups": "浏览所有群组 →",
+ "browseAllPledges": "浏览所有悬赏 →",
"pending": "等待审批",
"pendingDesc": "网络上尚未被任何 Soapbox 团队版主批准或隐藏的活动。",
"pendingEmpty": "没有等待审查的内容。",
diff --git a/src/pages/CampaignsPage.tsx b/src/pages/CampaignsPage.tsx
index 43d128ad..c01f04ef 100644
--- a/src/pages/CampaignsPage.tsx
+++ b/src/pages/CampaignsPage.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// 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. */
+
+
+
+
+
+
+ ...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.
+ ,
+ // 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.
+ ,
+ ]}
+ />
+
+
+ {t('campaigns.home.heroBody')}
+
+
+ {/* Primary CTA — solid brand-orange pill. The dark hero gives
+ the brand color the spotlight without competing with it. */}
+
+
+ {!loggedIn && (
+
+ )}
+
+ );
+}
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// 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 (
-
- {/* 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. */}
-
-
-
-
-
-
- ...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.
- ,
- // 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.
- ,
- ]}
- />
-
-
- {t('campaigns.home.heroBody')}
-
-
- {/* Primary CTA — solid brand-orange pill. The dark hero gives
- the brand color the spotlight without competing with it. */}
-
-
- {!user && (
-
- )}
-
-
-
-
-
-
- {/* 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)) && (
-
-
-
-
-
- )}
-
- {/* Community Campaigns — approved-and-not-hidden, minus featured.
- Skeletons until the moderator pack + label state both resolve,
- so we never flash an unmoderated grid. */}
-
-
-
- )}
-
- {/* 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 && (
-
-