Merge profile Agora tabs
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user