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;