From 7ccff2fbad6e56c995b1111563f1b940d7b0ae90 Mon Sep 17 00:00:00 2001 From: lemon Date: Thu, 28 May 2026 13:47:02 -0700 Subject: [PATCH] Turn the home page into a featured-only launchpad for all three surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/locales/ar.json | 2 + src/locales/en.json | 2 + src/locales/es.json | 2 + src/locales/fa.json | 2 + src/locales/fr.json | 2 + src/locales/hi.json | 2 + src/locales/id.json | 2 + src/locales/km.json | 2 + src/locales/ps.json | 2 + src/locales/pt.json | 2 + src/locales/ru.json | 2 + src/locales/sn.json | 2 + src/locales/sw.json | 2 + src/locales/tr.json | 2 + src/locales/zh-Hant.json | 2 + src/locales/zh.json | 2 + src/pages/CampaignsPage.tsx | 766 +++++++++++++++++++----------------- 17 files changed, 446 insertions(+), 352 deletions(-) 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. */ +
+ + +
+
+

+ ...) 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>` 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 && ( + + )} +
+
+
+
+ ); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Section header +// ═══════════════════════════════════════════════════════════════════════════════ + +function SectionHeader({ + title, + description, + browseLabel, + browseHref, +}: { + title: string; + description: string; + browseLabel: string; + browseHref: string; +}) { + return ( +
+
+

{title}

+

{description}

+
+ +
+ ); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// 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. */} -
- - -
-
-

- ...) 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>` 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)) && ( -
-
-
-

{t('campaigns.home.featured')}

-

- {t('campaigns.home.featuredDesc', { appName: config.appName })} -

-
-
- - -
- )} - - {/* Community Campaigns — approved-and-not-hidden, minus featured. - Skeletons until the moderator pack + label state both resolve, - so we never flash an unmoderated grid. */} -
-
-
-

{t('campaigns.home.community')}

-

- {t('campaigns.home.communityDesc')} -

-
- -
- - {moderatorsLoading || !moderationReady || approvedLoading ? ( - - ) : mainGridCampaigns.length === 0 ? ( - - ) : ( -
- {mainGridCampaigns.map((campaign) => ( - - ))} -
- )} - - {/* "Browse all campaigns" link — reveals the page that includes - campaigns not yet moderated (and, optionally, hidden ones). */} -
- -
-
- - {/* Moderator-only: campaigns awaiting an approval decision. */} - {isMod && ( - } - title={t('campaigns.home.pending')} - description={t('campaigns.home.pendingDesc')} - count={pendingCampaigns.length} - isLoading={allLoading} - emptyText={t('campaigns.home.pendingEmpty')} - skeleton={} - > -
- {pendingCampaigns.map((campaign) => ( - - ))} -
-
- )} - - {/* Moderator-only: campaigns currently hidden. */} - {isMod && ( - } - title={t('campaigns.home.hidden')} - description={t('campaigns.home.hiddenDesc')} - count={hiddenCampaigns.length} - isLoading={allLoading} - emptyText={t('campaigns.home.hiddenEmpty')} - skeleton={} - > -
- {hiddenCampaigns.map((campaign) => ( - - ))} -
-
- )} - - {/* 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 && ( -
-
-

- - {t('campaigns.home.yourCampaigns')} -

-

- {t('campaigns.home.yourCampaignsDesc')} -

-
-
- {yourPendingCampaigns.map((campaign) => ( - - ))} -
-
- )} -
-
+ + ); } @@ -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 (
{Array.from({ length: skeletonCount }).map((_, i) => ( - + ))}
); @@ -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(() => { + if (!featuredOrgs) return []; + const hiddenCoords = orgModeration?.hiddenCoords ?? new Set(); + 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 ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
+
+ + + {isLoading ? ( + + {Array.from({ length: 8 }).map((_, i) => ( + + ))} + + ) : ( + + {featuredGroups.map((community) => ( + + ))} + + )} +
); } -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(() => { + 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 ( - - - -
-

{t('campaigns.home.empty')}

-

- {t('campaigns.home.emptyHint', { appName: config.appName })} -

+
+ + + {isLoading ? ( +
+ {Array.from({ + length: Math.max( + 1, + Math.min(MAX_FEATURED_PER_SECTION, featuredPledgeCoords.length || 4), + ), + }).map((_, i) => ( + + ))}
- - + ) : orderedFeaturedPledges.length > 0 ? ( +
+ {orderedFeaturedPledges.map((action) => ( + + ))} +
+ ) : ( + // Defensive — featured coords resolved to a non-empty set but the + // events didn't come back (e.g. relay miss). Collapse silently. + null + )} +
+ ); +} + +function PledgeSkeleton() { + return ( + + +
+ + + + + +
); } +// Re-exported so AppRouter's lazy import shape stays identical. export default CampaignsPage;