From e7ca4cc9b1bae98da6781098d8764da678bff005 Mon Sep 17 00:00:00 2001 From: lemon Date: Sat, 20 Jun 2026 23:13:34 -0700 Subject: [PATCH] Merge profile Agora tabs --- src/components/profile/ProfileAgoraTab.tsx | 160 +++++++++++++++ .../profile/ProfileCampaignsTab.tsx | 188 ------------------ .../profile/ProfileIdentityRail.tsx | 6 +- src/components/profile/ProfileTabs.tsx | 7 +- src/components/profile/ProfileVerifiedTab.tsx | 70 ------- src/pages/ProfilePage.tsx | 120 +++++------ 6 files changed, 210 insertions(+), 341 deletions(-) create mode 100644 src/components/profile/ProfileAgoraTab.tsx delete mode 100644 src/components/profile/ProfileCampaignsTab.tsx delete mode 100644 src/components/profile/ProfileVerifiedTab.tsx diff --git a/src/components/profile/ProfileAgoraTab.tsx b/src/components/profile/ProfileAgoraTab.tsx new file mode 100644 index 00000000..8166108f --- /dev/null +++ b/src/components/profile/ProfileAgoraTab.tsx @@ -0,0 +1,160 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { BadgeCheck, Megaphone } from 'lucide-react'; + +import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard'; +import { ProfileOverviewSections } from '@/components/profile/ProfileIdentityRail'; +import { ProfileVerifierSection } from '@/components/profile/ProfileVerifierSection'; +import { Badge } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; +import { StartCampaignLink } from '@/components/StartCampaignLink'; +import { useCampaignModeration } from '@/hooks/useCampaignModeration'; +import { useVerifiedCampaigns } from '@/hooks/useVerifiedCampaigns'; +import type { ProfileCampaignStats } from '@/hooks/useProfileCampaignStats'; +import type { Action } from '@/hooks/useActions'; +import type { ParsedCampaign } from '@/lib/campaign'; + +interface ProfileAgoraTabProps { + pubkey: string; + displayName: string; + isOwnProfile: boolean; + profileCampaignStats: ProfileCampaignStats; + campaigns: ParsedCampaign[]; + pledges: Action[]; + btcPrice: number | undefined; + onTabChange: (tabId: string) => void; +} + +interface CampaignRelationship { + campaign: ParsedCampaign; + authored: boolean; + verified: boolean; +} + +/** + * Unified Agora profile tab. + * + * Merges the old Overview, Verified, and Campaigns profile concepts into + * one surface while keeping Activity separate. Campaigns are keyed by their + * canonical `a` coordinate so a profile that authors and verifies the same + * campaign only renders one card. + */ +export function ProfileAgoraTab({ + pubkey, + displayName, + isOwnProfile, + profileCampaignStats, + campaigns, + pledges, + btcPrice, + onTabChange, +}: ProfileAgoraTabProps) { + const { t } = useTranslation(); + const { campaigns: verifiedCampaigns, isLoading: verifiedLoading } = useVerifiedCampaigns(pubkey); + const { data: moderation } = useCampaignModeration(); + + const authoredLoading = profileCampaignStats.isVerifying && campaigns.length === 0; + + const mergedCampaigns = useMemo(() => { + const byCoord = new Map(); + + for (const campaign of campaigns) { + if (moderation.hiddenCoords.has(campaign.aTag)) continue; + byCoord.set(campaign.aTag, { campaign, authored: true, verified: false }); + } + + for (const campaign of verifiedCampaigns) { + if (moderation.hiddenCoords.has(campaign.aTag)) continue; + const existing = byCoord.get(campaign.aTag); + if (existing) { + existing.verified = true; + } else { + byCoord.set(campaign.aTag, { campaign, authored: false, verified: true }); + } + } + + return [...byCoord.values()].sort((a, b) => b.campaign.createdAt - a.campaign.createdAt); + }, [campaigns, verifiedCampaigns, moderation.hiddenCoords]); + + const isLoading = (authoredLoading || verifiedLoading) && mergedCampaigns.length === 0; + + return ( +
+ + + + + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : mergedCampaigns.length === 0 ? ( + +
+ {isOwnProfile ? ( + + ) : ( + + )} +

+ {isOwnProfile + ? t('profile.campaigns.emptySelf') + : t('profile.campaigns.emptyOther', { name: displayName })} +

+ {isOwnProfile && ( + + {t('profile.campaigns.startLink')} + + )} +
+
+ ) : ( +
+

+ {t('profile.campaigns.count', { count: mergedCampaigns.length })} +

+
+ {mergedCampaigns.map(({ campaign, authored, verified }) => ( + } + /> + ))} +
+
+ )} +
+ ); +} + +function CampaignRelationshipBadges({ authored, verified }: { authored: boolean; verified: boolean }) { + const { t } = useTranslation(); + + return ( + <> + {authored && ( + + {t('profile.badges.founder')} + + )} + {verified && ( + + {t('campaignsDetail.verifiedLabel')} + + )} + + ); +} diff --git a/src/components/profile/ProfileCampaignsTab.tsx b/src/components/profile/ProfileCampaignsTab.tsx deleted file mode 100644 index 9b3d7736..00000000 --- a/src/components/profile/ProfileCampaignsTab.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Megaphone } from 'lucide-react'; -import { useQueries } from '@tanstack/react-query'; - -import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard'; -import { StartCampaignLink } from '@/components/StartCampaignLink'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { useCampaignModeration } from '@/hooks/useCampaignModeration'; -import { useCampaignModerators } from '@/hooks/useCampaignModerators'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useAppContext } from '@/hooks/useAppContext'; -import { fetchAddressData } from '@/lib/bitcoin'; -import type { ParsedCampaign } from '@/lib/campaign'; - -interface ProfileCampaignsTabProps { - pubkey: string; - displayName: string; - isOwnProfile: boolean; - campaigns: ParsedCampaign[]; - isLoading: boolean; -} - -type SortMode = 'top' | 'new'; - -/** - * Full grid of every campaign authored by this profile. - * - * Owner / moderator can toggle "Show hidden" to see campaigns the - * moderation pack has hidden from the home page — visitors only see - * non-hidden campaigns by default. Sort modes mirror - * {@link AllCampaignsPage}: New (newest created_at first, the default - * incoming order) and Top (most sats raised, requires the verified - * donation totals). - */ -export function ProfileCampaignsTab({ - pubkey, - displayName, - isOwnProfile, - campaigns, - isLoading, -}: ProfileCampaignsTabProps) { - const { t } = useTranslation(); - const { user } = useCurrentUser(); - const { data: moderation } = useCampaignModeration(); - const { data: moderators } = useCampaignModerators(); - const isModerator = !!user && (moderators ?? []).includes(user.pubkey); - - const [sortMode, setSortMode] = useState('new'); - const [showHidden, setShowHidden] = useState(false); - - const canShowHidden = isOwnProfile || isModerator; - - const filtered = useMemo(() => { - if (canShowHidden && showHidden) return campaigns; - return campaigns.filter((c) => !moderation.hiddenCoords.has(c.aTag)); - }, [campaigns, canShowHidden, showHidden, moderation.hiddenCoords]); - - if (isLoading && filtered.length === 0) { - return ( -
-
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
-
- ); - } - - if (filtered.length === 0) { - return ( -
- -
- -

- {isOwnProfile - ? t('profile.campaigns.emptySelf') - : t('profile.campaigns.emptyOther', { name: displayName })} -

- {isOwnProfile && ( - - {t('profile.campaigns.startLink')} - - )} -
-
-
- ); - } - - return ( -
-
-

- {t('profile.campaigns.count', { count: filtered.length })} -

-
- - - {canShowHidden && ( - - )} -
-
- - {sortMode === 'top' ? ( - - ) : ( -
- {filtered.map((c) => ( - - ))} -
- )} -
- ); -} - -/** - * Sorts the visible campaigns by sats raised (descending) by fanning - * out one address-balance query per on-chain campaign. Uses `useQueries`, - * so the hook call count is deterministic per render (one queries-tuple, - * not one hook per campaign) and the rules of hooks hold. - * - * Caches share keys with `useCampaignDonations` so the balance results - * are reused across the profile and any other view of the same campaign. - */ -function SortedByTopGrid({ campaigns }: { campaigns: ParsedCampaign[] }) { - const { config } = useAppContext(); - const { esploraApis } = config; - - // Only on-chain campaigns can have observable totals. SP-only campaigns sort to 0. - const onchain = campaigns.flatMap((c) => { - const address = c.wallets?.onchain?.value; - return address ? [{ campaign: c, address }] : []; - }); - - const balanceQueries = useQueries({ - queries: onchain.map(({ address }) => ({ - queryKey: ['bitcoin-balance', 'campaign', esploraApis, address], - queryFn: ({ signal }: { signal: AbortSignal }) => - fetchAddressData(address, esploraApis, signal), - staleTime: 60_000, - enabled: !!address, - })), - }); - - const totalsByCoord = new Map(); - for (let i = 0; i < onchain.length; i++) { - const sats = balanceQueries[i]?.data?.totalReceived ?? 0; - totalsByCoord.set(onchain[i].campaign.aTag, sats); - } - - const sorted = [...campaigns].sort( - (a, b) => (totalsByCoord.get(b.aTag) ?? 0) - (totalsByCoord.get(a.aTag) ?? 0), - ); - - return ( -
- {sorted.map((campaign) => ( - - ))} -
- ); -} diff --git a/src/components/profile/ProfileIdentityRail.tsx b/src/components/profile/ProfileIdentityRail.tsx index 98daee2f..c916c59d 100644 --- a/src/components/profile/ProfileIdentityRail.tsx +++ b/src/components/profile/ProfileIdentityRail.tsx @@ -110,7 +110,7 @@ const RAIL_ORG_LIMIT = 4; * * The rail does NOT own the tab bar or the tab content — those live in * the right column. Click handlers like `onTabChange` exist so rail rows - * can switch tabs (e.g. "See all campaigns →" jumps to the Campaigns tab). + * can switch tabs (e.g. "See all campaigns →" jumps to the Agora tab). */ export function ProfileIdentityRail({ pubkey, @@ -400,7 +400,7 @@ export function ProfileOverviewSections({ campaigns={campaigns} isOwnProfile={isOwnProfile} isLoading={campaignStats.isVerifying && campaigns.length === 0} - onSeeAll={() => onTabChange('campaigns')} + onSeeAll={() => onTabChange('agora')} /> {/* Latest pledge — surfaced as a fallback when this profile has @@ -650,7 +650,7 @@ function StatList({
{hasRaised && (