Merge profile Agora tabs

This commit is contained in:
lemon
2026-06-20 23:13:34 -07:00
parent 4d148218ac
commit e7ca4cc9b1
6 changed files with 210 additions and 341 deletions
+160
View File
@@ -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<string, CampaignRelationship>();
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 (
<div className="px-4 sm:px-6 py-6 space-y-6" data-pubkey={pubkey}>
<ProfileVerifierSection pubkey={pubkey} isOwnProfile={isOwnProfile} />
<ProfileOverviewSections
pubkey={pubkey}
isOwnProfile={isOwnProfile}
campaigns={campaigns}
campaignStats={profileCampaignStats}
pledges={pledges}
btcPrice={btcPrice}
onTabChange={onTabChange}
showOrganizations={false}
className="lg:hidden"
/>
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{Array.from({ length: 3 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
) : mergedCampaigns.length === 0 ? (
<Card className="border-dashed">
<div className="py-12 px-8 text-center">
{isOwnProfile ? (
<Megaphone className="size-10 mx-auto mb-3 text-muted-foreground" />
) : (
<BadgeCheck className="size-10 mx-auto mb-3 text-muted-foreground" />
)}
<p className="text-muted-foreground max-w-sm mx-auto">
{isOwnProfile
? t('profile.campaigns.emptySelf')
: t('profile.campaigns.emptyOther', { name: displayName })}
</p>
{isOwnProfile && (
<StartCampaignLink className="inline-block mt-4 text-sm font-medium text-primary hover:underline">
{t('profile.campaigns.startLink')}
</StartCampaignLink>
)}
</div>
</Card>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('profile.campaigns.count', { count: mergedCampaigns.length })}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{mergedCampaigns.map(({ campaign, authored, verified }) => (
<CampaignCard
key={campaign.aTag}
campaign={campaign}
footerBadge={<CampaignRelationshipBadges authored={authored} verified={verified} />}
/>
))}
</div>
</div>
)}
</div>
);
}
function CampaignRelationshipBadges({ authored, verified }: { authored: boolean; verified: boolean }) {
const { t } = useTranslation();
return (
<>
{authored && (
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
{t('profile.badges.founder')}
</Badge>
)}
{verified && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
{t('campaignsDetail.verifiedLabel')}
</Badge>
)}
</>
);
}
@@ -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<SortMode>('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 (
<div className="px-4 sm:px-6 py-6">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{Array.from({ length: 3 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
</div>
);
}
if (filtered.length === 0) {
return (
<div className="px-4 sm:px-6 py-12" data-pubkey={pubkey}>
<Card className="border-dashed">
<div className="py-12 px-8 text-center">
<Megaphone className="size-10 mx-auto mb-3 text-muted-foreground" />
<p className="text-muted-foreground max-w-sm mx-auto">
{isOwnProfile
? t('profile.campaigns.emptySelf')
: t('profile.campaigns.emptyOther', { name: displayName })}
</p>
{isOwnProfile && (
<StartCampaignLink
className="inline-block mt-4 text-sm font-medium text-primary hover:underline"
>
{t('profile.campaigns.startLink')}
</StartCampaignLink>
)}
</div>
</Card>
</div>
);
}
return (
<div className="px-4 sm:px-6 py-6 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{t('profile.campaigns.count', { count: filtered.length })}
</p>
<div className="flex items-center gap-2">
<Button
variant={sortMode === 'new' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setSortMode('new')}
>
{t('profile.campaigns.sortNew')}
</Button>
<Button
variant={sortMode === 'top' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setSortMode('top')}
>
{t('profile.campaigns.sortTop')}
</Button>
{canShowHidden && (
<Button
variant={showHidden ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setShowHidden((v) => !v)}
>
{showHidden ? t('profile.campaigns.hideHidden') : t('profile.campaigns.showHidden')}
</Button>
)}
</div>
</div>
{sortMode === 'top' ? (
<SortedByTopGrid campaigns={filtered} />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{filtered.map((c) => (
<CampaignCard key={c.aTag} campaign={c} />
))}
</div>
)}
</div>
);
}
/**
* 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<string, number>();
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 (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{sorted.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
);
}
@@ -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({
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
{hasRaised && (
<button
onClick={() => onTabChange('campaigns')}
onClick={() => onTabChange('agora')}
className="flex items-baseline gap-1.5 hover:opacity-80 transition-opacity"
>
<span className="font-bold tabular-nums text-orange-500 dark:text-orange-400">
+2 -5
View File
@@ -12,11 +12,8 @@ interface ProfileTabsProps {
*
* A focused alternative to the global `SubHeaderBar` — no arc decoration,
* no hover slice tracking, no FAB-aware spacing. Just a clean horizontal
* row with an animated underline marking the active tab. Used in two
* shapes on the profile page: a 3-tab content set on desktop (Activity /
* Campaigns / Pledges, alongside the sticky identity rail) and a 5-tab
* set on mobile (Overview / Activity / Campaigns / Community / Pledges,
* since the rail collapses into the Overview / Community tabs).
* row with an animated underline marking the active tab. The profile page
* currently uses the same compact content set on desktop and mobile.
*
* Behavior:
* - Sticks to the top of its containing scroll context. The parent column
@@ -1,70 +0,0 @@
import { useTranslation } from 'react-i18next';
import { BadgeCheck } from 'lucide-react';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { ProfileVerifierSection } from '@/components/profile/ProfileVerifierSection';
import { Card } from '@/components/ui/card';
import { useVerifiedCampaigns } from '@/hooks/useVerifiedCampaigns';
interface ProfileVerifiedTabProps {
pubkey: string;
displayName: string;
isOwnProfile?: boolean;
}
/**
* The profile's verification tab: the account's self-published
* "How We Verify" statement (kind 14672) followed by the grid of
* campaigns it has verified — resolved from the account's own
* `agora.verified` (kind 1985) labels via {@link useVerifiedCampaigns}.
* Surfaced as the default tab for verifier profiles so visitors
* immediately see how the organization vets campaigns and what it
* stands behind.
*/
export function ProfileVerifiedTab({ pubkey, displayName, isOwnProfile = false }: ProfileVerifiedTabProps) {
const { t } = useTranslation();
const { campaigns, isLoading } = useVerifiedCampaigns(pubkey);
if (isLoading && campaigns.length === 0) {
return (
<div className="px-4 sm:px-6 py-6 space-y-6">
<ProfileVerifierSection pubkey={pubkey} isOwnProfile={isOwnProfile} />
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{Array.from({ length: 3 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
</div>
);
}
if (campaigns.length === 0) {
return (
<div className="px-4 sm:px-6 py-6 space-y-6" data-pubkey={pubkey}>
<ProfileVerifierSection pubkey={pubkey} isOwnProfile={isOwnProfile} />
<Card className="border-dashed">
<div className="py-12 px-8 text-center">
<BadgeCheck className="size-10 mx-auto mb-3 text-muted-foreground" />
<p className="text-muted-foreground max-w-sm mx-auto">
{t('profile.verified.empty', { name: displayName })}
</p>
</div>
</Card>
</div>
);
}
return (
<div className="px-4 sm:px-6 py-6 space-y-4">
<ProfileVerifierSection pubkey={pubkey} isOwnProfile={isOwnProfile} className="mb-2" />
<p className="text-sm text-muted-foreground">
{t('profile.verified.count', { count: campaigns.length })}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{campaigns.map((c) => (
<CampaignCard key={c.aTag} campaign={c} />
))}
</div>
</div>
);
}
+45 -75
View File
@@ -58,14 +58,12 @@ import {
ProfileAvatarBlock,
ProfileIdentityHeader,
ActionBar,
ProfileOverviewSections,
ProfileOrganizationsSection,
} from '@/components/profile/ProfileIdentityRail';
import { ProfileCampaignsTab } from '@/components/profile/ProfileCampaignsTab';
import { ProfilePledgesTab } from '@/components/profile/ProfilePledgesTab';
import { ProfileActivityTab } from '@/components/profile/ProfileActivityTab';
import { ProfileTabs } from '@/components/profile/ProfileTabs';
import { ProfileVerifiedTab } from '@/components/profile/ProfileVerifiedTab';
import { ProfileAgoraTab } from '@/components/profile/ProfileAgoraTab';
import type { ParsedCampaign } from '@/lib/campaign';
import type { AddrCoords } from '@/hooks/useEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
@@ -772,9 +770,8 @@ interface ProfileTabContentProps {
/**
* Single source of truth for tab body rendering — used by both the
* desktop and mobile layouts. Desktop only ever passes one of
* `activity` / `campaigns` / `pledges`; mobile additionally passes
* `overview` and `community`.
* desktop and mobile layouts. The Agora tab owns the old Overview,
* Verified, and Campaigns concepts; Activity remains separate.
*/
function ProfileTabContent({
activeTab,
@@ -788,21 +785,18 @@ function ProfileTabContent({
pledges,
onTabChange,
}: ProfileTabContentProps) {
if (activeTab === 'overview') {
if (activeTab === 'agora' || activeTab === 'overview' || activeTab === 'verified' || activeTab === 'campaigns') {
return (
<div className="pt-5">
<ProfileOverviewSections
pubkey={pubkey}
isOwnProfile={isOwnProfile}
campaigns={campaigns}
campaignStats={profileCampaignStats}
pledges={pledges}
btcPrice={btcPrice}
onTabChange={onTabChange}
// Organizations has its own tab on mobile.
showOrganizations={false}
/>
</div>
<ProfileAgoraTab
pubkey={pubkey}
displayName={displayName}
isOwnProfile={isOwnProfile}
profileCampaignStats={profileCampaignStats}
campaigns={campaigns}
pledges={pledges}
btcPrice={btcPrice}
onTabChange={onTabChange}
/>
);
}
@@ -814,22 +808,6 @@ function ProfileTabContent({
);
}
if (activeTab === 'verified') {
return <ProfileVerifiedTab pubkey={pubkey} displayName={displayName} isOwnProfile={isOwnProfile} />;
}
if (activeTab === 'campaigns') {
return (
<ProfileCampaignsTab
pubkey={pubkey}
displayName={displayName}
isOwnProfile={isOwnProfile}
campaigns={profileCampaignStats.campaigns}
isLoading={profileCampaignStats.isVerifying && profileCampaignStats.campaigns.length === 0}
/>
);
}
if (activeTab === 'pledges') {
return (
<ProfilePledgesTab
@@ -849,19 +827,14 @@ function ProfileTabContent({
// ----- Main Component -----
// Desktop (lg+) keeps the focused content set; the rail to the
// left already shows the profile's Overview information (campaigns,
// orgs), so duplicating it as a tab would be redundant.
// "Groups" and "Pledges" are temporarily hidden.
const DESKTOP_TAB_LABEL_KEYS = ['activity', 'campaigns'] as const;
// Below lg the left rail is unavailable, so its content becomes the
// default "Overview" tab. Order matters — "Overview" is the default on
// first mount. "Groups" and "Pledges" are temporarily hidden.
const MOBILE_TAB_LABEL_KEYS = ['overview', 'activity', 'campaigns'] as const;
// Profile keeps Activity separate and folds the old Overview, Verified,
// and Campaigns tabs into one Agora surface. Groups and Pledges are
// temporarily hidden.
const PROFILE_TAB_LABEL_KEYS = ['agora', 'activity'] as const;
// Map from label key → internal tab id.
const CORE_TAB_IDS: Record<string, string> = {
'agora': 'agora',
'overview': 'overview',
'verified': 'verified',
'activity': 'activity',
@@ -870,8 +843,8 @@ const CORE_TAB_IDS: Record<string, string> = {
'pledges': 'pledges',
};
const KNOWN_TAB_IDS = new Set(['overview', 'verified', 'activity', 'campaigns', 'community', 'pledges']);
const DESKTOP_TAB_IDS = new Set(['verified', 'activity', 'campaigns']);
const KNOWN_TAB_IDS = new Set(['agora', 'overview', 'verified', 'activity', 'campaigns', 'community', 'pledges']);
const DESKTOP_TAB_IDS = new Set(['agora', 'activity']);
/**
* Read the viewport at first render to pick the initial active tab.
@@ -882,7 +855,7 @@ const DESKTOP_TAB_IDS = new Set(['verified', 'activity', 'campaigns']);
*/
function getInitialActiveTab(): string {
if (typeof window === 'undefined' || !window.matchMedia) return 'activity';
return window.matchMedia('(min-width: 1024px)').matches ? 'activity' : 'overview';
return window.matchMedia('(min-width: 1024px)').matches ? 'activity' : 'agora';
}
export function ProfilePage() {
@@ -1082,8 +1055,8 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
}
// Is the profile being viewed a verifier (has a kind 14672 statement)?
// When so, a "Verified" tab is shown and becomes the default on first
// load. The flag is async, so the default switch happens in an effect.
// When so, Agora becomes the default on first load. The flag is async,
// so the default switch happens in an effect.
const { isVerifier } = useVerifierStatement(pubkey);
// True once the user has explicitly picked a tab, so the verifier
@@ -1094,10 +1067,10 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
setActiveTab(tabId);
}, []);
// Default verifier profiles to the "Verified" tab on first load.
// Default verifier profiles to the Agora tab on first load.
useEffect(() => {
if (isVerifier && !tabManuallySelected.current) {
setActiveTab('verified');
setActiveTab('agora');
}
}, [isVerifier]);
@@ -1107,38 +1080,35 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe
tabManuallySelected.current = false;
}, [pubkey]);
// Two tab lists — mobile (Overview + Community visible) and desktop
// (only the three content tabs; the rail covers Overview / Community
// visually). Both `ProfileTabs` instances feed the same `activeTab`,
// so cross-breakpoint navigation (e.g. clicking "See all" in the
// mobile Overview tab) Just Works.
// Verifier profiles get a "Verified" tab, surfaced first (and made the
// default in the effect above) so visitors immediately see the campaigns
// the organization has vouched for. Non-verifiers never see the tab.
// Two tab lists are kept for layout symmetry, but both now expose the
// same content set: Agora and Activity. The rail still covers desktop
// identity details while Agora contains those sections on mobile.
const desktopTabs = useMemo(() => {
const keys = isVerifier
? (['verified', ...DESKTOP_TAB_LABEL_KEYS] as const)
: DESKTOP_TAB_LABEL_KEYS;
return keys.map((key) => ({ id: CORE_TAB_IDS[key], label: t(`profile.tabs.${key}`) }));
}, [t, isVerifier]);
return PROFILE_TAB_LABEL_KEYS.map((key) => ({
id: CORE_TAB_IDS[key],
label: key === 'agora' ? t('search.tabs.agora') : t(`profile.tabs.${key}`),
}));
}, [t]);
const mobileTabs = useMemo(() => {
const keys = isVerifier
? (['verified', ...MOBILE_TAB_LABEL_KEYS] as const)
: MOBILE_TAB_LABEL_KEYS;
return keys.map((key) => ({ id: CORE_TAB_IDS[key], label: t(`profile.tabs.${key}`) }));
}, [t, isVerifier]);
return PROFILE_TAB_LABEL_KEYS.map((key) => ({
id: CORE_TAB_IDS[key],
label: key === 'agora' ? t('search.tabs.agora') : t(`profile.tabs.${key}`),
}));
}, [t]);
// Keep the active tab in sync if it ever falls out of the recognized
// set. The desktop case also redirects mobile-only tabs (`overview`,
// `community`) back to `activity` so a user resizing from mobile to
// desktop while on Overview doesn't end up looking at an empty
// content column (the rail already shows what they were viewing).
// set. Legacy Agora-adjacent tab ids collapse into the new unified tab;
// desktop still redirects mobile-only community content to Activity.
useEffect(() => {
const isKnown = KNOWN_TAB_IDS.has(activeTab);
if (!isKnown) {
setActiveTab(getInitialActiveTab());
return;
}
if (activeTab === 'overview' || activeTab === 'verified' || activeTab === 'campaigns') {
setActiveTab('agora');
return;
}
if (typeof window === 'undefined' || !window.matchMedia) return;
const desktopMq = window.matchMedia('(min-width: 1024px)');
const onChange = () => {