Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/components/common/ActionAttributionLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import {
ACTOR_ROLE_BADGE_CLASSES,
type ActionAttribution,
} from '@/utils/actionAttribution.utils';
import { copyTextToClipboard } from '@/utils/clipboard.utils';
import { truncateWalletAddress } from '@/utils/referral.utils';
import showToast from '@/utils/toast.util';

interface ActionAttributionLabelProps {
attribution: ActionAttribution;
/** Lead-in copy, e.g. "Posted by" for announcements or "by" for activity. */
prefix?: string;
className?: string;
}

/**
* Shows who performed a creator-key action (#1224). Primary-creator actions
* get a "Creator" label; co-creator actions get their role badge. The wallet
* opens a popover with the full address and a copy button — a click-driven
* popover rather than a hover tooltip so it works on touch and keyboard too.
*/
export default function ActionAttributionLabel({
attribution,
prefix,
className,
}: ActionAttributionLabelProps) {
const [copied, setCopied] = useState(false);
const { wallet, role } = attribution;
const isCreator = role === 'creator';

const handleCopy = async () => {
try {
await copyTextToClipboard(wallet);
} catch {
showToast.error('Could not copy address');
return;
}
setCopied(true);
showToast.success('Address copied');
setTimeout(() => setCopied(false), 2000);
};

return (
<span
data-testid="action-attribution"
data-role={role}
className={cn(
'inline-flex min-w-0 flex-wrap items-center gap-1.5 text-xs text-white/50',
className
)}
>
{prefix && <span>{prefix}</span>}
<Popover onOpenChange={open => !open && setCopied(false)}>
<PopoverTrigger asChild>
<button
type="button"
data-testid="action-attribution-wallet"
aria-label={`Show full address for ${truncateWalletAddress(wallet)}`}
className="rounded font-mono text-white/80 underline decoration-dotted underline-offset-2 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
>
{truncateWalletAddress(wallet)}
</button>
</PopoverTrigger>
<PopoverContent
data-testid="action-attribution-popover"
className="w-auto max-w-[min(22rem,calc(100vw-2rem))] border-white/10 bg-slate-950 p-3 text-white"
>
<p className="mb-2 text-[0.65rem] font-semibold uppercase tracking-wider text-white/50">
{isCreator ? 'Creator wallet' : `${role} co-creator wallet`}
</p>
<div className="flex items-start gap-2">
<code
data-testid="action-attribution-full-address"
className="min-w-0 flex-1 break-all font-mono text-xs text-white/90"
>
{wallet}
</code>
<button
type="button"
onClick={() => void handleCopy()}
data-testid="action-attribution-copy"
aria-label={copied ? 'Address copied' : 'Copy address'}
className="shrink-0 rounded-md border border-white/15 p-1.5 text-white/70 hover:bg-white/10 hover:text-white"
>
{copied ? (
<Check className="size-3.5 text-emerald-400" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
</button>
</div>
</PopoverContent>
</Popover>
<span
data-testid="action-attribution-role"
className={cn(
'inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
isCreator
? 'border-amber-400/25 bg-amber-400/10 text-amber-200'
: ACTOR_ROLE_BADGE_CLASSES[role]
)}
>
{isCreator ? 'Creator' : role}
</span>
</span>
);
}
8 changes: 8 additions & 0 deletions src/components/common/AnnouncementItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type Announcement,
} from '@/utils/announcements.utils';
import { useRelativeTime } from '@/hooks/useRelativeTime';
import ActionAttributionLabel from '@/components/common/ActionAttributionLabel';

export interface AnnouncementItemProps {
announcement: Announcement;
Expand Down Expand Up @@ -55,6 +56,13 @@ const AnnouncementItem: React.FC<AnnouncementItemProps> = ({
<h3 className="text-sm font-semibold text-white" data-testid="announcement-title">
{announcement.title}
</h3>
{announcement.postedBy && (
<ActionAttributionLabel
attribution={announcement.postedBy}
prefix="Posted by"
className="mt-1"
/>
)}
{!open && announcement.summary && (
<p className="mt-1 text-xs text-white/60">{announcement.summary}</p>
)}
Expand Down
164 changes: 164 additions & 0 deletions src/components/common/BadgeProgressTracker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { useEffect, useMemo, useRef } from 'react';
import { format, isValid, parseISO } from 'date-fns';
import { CheckCircle2 } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
import Skeleton from '@/components/ui/skeleton';
import {
useWalletBadgeMetrics,
useWalletBadges,
useWalletHoldings,
} from '@/hooks/useWallet';
import { cn } from '@/lib/utils';
import { formatNumber } from '@/utils/numberFormat.utils';
import { computeBadgeProgress } from '@/utils/walletBadges.utils';

interface BadgeProgressTrackerProps {
wallet: string;
className?: string;
}

function formatEarnedDate(earnedAt: string): string {
const date = parseISO(earnedAt);
return isValid(date) ? format(date, 'MMM d, yyyy') : 'Date unavailable';
}

const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3';

/**
* Badge criteria progress tracker for the portfolio page (#1225).
*
* Unearned badges show their criteria and a progress bar measured against the
* wallet's activity metrics; earned badges show a green check and earn date.
* The section is hidden once every badge has been earned. Progress refreshes
* on a poll and whenever the wallet's holdings change (a new trade or hold).
*/
export default function BadgeProgressTracker({
wallet,
className,
}: BadgeProgressTrackerProps) {
const badgesQuery = useWalletBadges(wallet);
const metricsQuery = useWalletBadgeMetrics(wallet);
const { dataUpdatedAt: holdingsUpdatedAt } = useWalletHoldings(wallet);

const { refetch: refetchBadges } = badgesQuery;
const { refetch: refetchMetrics } = metricsQuery;
const lastHoldingsUpdateRef = useRef(holdingsUpdatedAt);
useEffect(() => {
const previous = lastHoldingsUpdateRef.current;
lastHoldingsUpdateRef.current = holdingsUpdatedAt;
// Skip the first holdings load; only react to later trade/hold refreshes.
if (!previous || holdingsUpdatedAt === previous) return;
void refetchBadges();
void refetchMetrics();
}, [holdingsUpdatedAt, refetchBadges, refetchMetrics]);

const { earned, inProgress } = useMemo(
() => computeBadgeProgress(badgesQuery.data ?? [], metricsQuery.data),
[badgesQuery.data, metricsQuery.data]
);

const isLoading = badgesQuery.isLoading || metricsQuery.isLoading;
const isError = badgesQuery.isError || metricsQuery.isError;

if (!isLoading && !isError && inProgress.length === 0) return null;

return (
<section
data-testid="badge-progress-tracker"
aria-labelledby="badge-progress-heading"
className={cn('space-y-3', className)}
>
<div>
<h2
id="badge-progress-heading"
className="text-lg font-semibold text-white"
>
Badge Progress
</h2>
<p className="text-sm text-white/60">
How close this wallet is to earning each badge.
</p>
</div>

{isLoading ? (
<div className={GRID_CLASS} data-testid="badge-progress-loading">
{Array.from({ length: 3 }, (_, index) => (
<Skeleton key={index} className="h-28 w-full" />
))}
</div>
) : isError ? (
<div className="flex items-center gap-3 rounded-lg border border-white/10 p-4 text-sm text-white/70">
<span>Couldn't load badge progress.</span>
<button
type="button"
onClick={() => {
void refetchBadges();
void refetchMetrics();
}}
className="rounded-md border border-white/20 px-3 py-1 text-white hover:bg-white/10"
>
Retry
</button>
</div>
) : (
<div className={GRID_CLASS}>
{inProgress.map(badge => {
const Icon = badge.icon;
const percent = Math.round(badge.percent);
return (
<article
key={badge.id}
data-testid={`badge-progress-${badge.id}`}
className="flex flex-col gap-3 rounded-lg border border-white/10 bg-white/[0.03] p-4"
>
<div className="flex items-center gap-3">
<Icon className="size-6 shrink-0 text-white/70" aria-hidden="true" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-white">
{badge.name}
</h3>
<p className="text-xs text-white/60">{badge.criteria}</p>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-white/60">
<span data-testid={`badge-progress-${badge.id}-value`}>
{formatNumber(badge.current)} / {formatNumber(badge.target)}{' '}
{badge.unit}
</span>
<span className="font-mono tabular-nums">{percent}%</span>
</div>
<Progress
value={percent}
aria-label={`${badge.name} badge progress`}
data-testid={`badge-progress-${badge.id}-bar`}
className="h-1.5 bg-white/10"
/>
</div>
</article>
);
})}

{earned.map(badge => (
<article
key={badge.id}
data-testid={`badge-progress-earned-${badge.id}`}
className="flex items-center gap-3 rounded-lg border border-emerald-400/20 bg-emerald-400/[0.06] p-4"
>
<CheckCircle2
className="size-6 shrink-0 text-emerald-400"
aria-label="Earned"
/>
<div className="min-w-0">
<h3 className="text-sm font-medium text-white">{badge.name}</h3>
<p className="text-xs text-emerald-300/80">
Earned {formatEarnedDate(badge.earnedAt)}
</p>
</div>
</article>
))}
</div>
)}
</section>
);
}
Loading
Loading