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
45 changes: 45 additions & 0 deletions src/components/common/ReserveBalanceChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { AlertTriangle, Info } from 'lucide-react';
import { Tooltip } from '@/components/ui/tooltip';
import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';
import {
getKeyReserveStatus,
RESERVE_TOOLTIP_TEXT,
type KeyReserve,
} from '@/utils/keyReserve.utils';

interface ReserveBalanceChipProps {
reserve: KeyReserve;
}

/** Reserve balance value for the key detail stats row, with a low-reserve warning (#1208). */
export default function ReserveBalanceChip({ reserve }: ReserveBalanceChipProps) {
const { visible, isLow } = getKeyReserveStatus(reserve);
if (!visible) return null;

return (
<div className="flex flex-wrap items-center gap-2">
<span data-testid="reserve-balance-value">
{formatDisplayKeyPrice(reserve.reserveStroops)}
</span>
<Tooltip content={RESERVE_TOOLTIP_TEXT}>
<button
type="button"
aria-label="What is the reserve balance?"
data-testid="reserve-balance-info"
className="inline-flex text-white/45 hover:text-white/80"
>
<Info className="size-3.5" aria-hidden="true" />
</button>
</Tooltip>
{isLow && (
<span
data-testid="reserve-low-warning"
className="inline-flex items-center gap-1 rounded-full border border-amber-400/30 bg-amber-400/15 px-2 py-0.5 text-[0.65rem] font-bold uppercase tracking-wide text-amber-300"
>
<AlertTriangle className="size-3" aria-hidden="true" />
Low reserve
</span>
)}
</div>
);
}
40 changes: 40 additions & 0 deletions src/components/common/__tests__/ReserveBalanceChip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import ReserveBalanceChip from '@/components/common/ReserveBalanceChip';
import { RESERVE_TOOLTIP_TEXT } from '@/utils/keyReserve.utils';

describe('ReserveBalanceChip', () => {
it('renders the balance and the explanatory tooltip', () => {
render(
<ReserveBalanceChip
reserve={{ reserveStroops: 500_000_000, marketCapStroops: 1_000_000_000 }}
/>
);

expect(screen.getByTestId('reserve-balance-value')).toHaveTextContent('50');
expect(screen.getByRole('tooltip')).toHaveTextContent(RESERVE_TOOLTIP_TEXT);
expect(screen.queryByTestId('reserve-low-warning')).not.toBeInTheDocument();
});

it('shows the low reserve warning below 10% of market cap', () => {
render(
<ReserveBalanceChip
reserve={{ reserveStroops: 50_000_000, marketCapStroops: 1_000_000_000 }}
/>
);

expect(screen.getByTestId('reserve-low-warning')).toHaveTextContent(
'Low reserve'
);
});

it('renders nothing for a key with zero reserve', () => {
const { container } = render(
<ReserveBalanceChip
reserve={{ reserveStroops: 0, marketCapStroops: 1_000_000_000 }}
/>
);

expect(container).toBeEmptyDOMElement();
});
});
77 changes: 66 additions & 11 deletions src/components/marketplace/CampaignCard.tsx
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Link } from 'react-router';
import { BadgeCheck, Clock, Gift } from 'lucide-react';
import { BadgeCheck, CheckCircle2, Clock, Gift } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import {
canClaimCampaignReward,
describeCampaignCriteria,
getCampaignEligibility,
type Campaign,
type CampaignStatus,
} from '@/utils/campaign.utils';
import { formatNumber } from '@/utils/numberFormat.utils';
import { formatSpotlightTimeRemaining } from '@/utils/spotlightAuction.utils';
Expand All @@ -15,25 +18,54 @@ interface CampaignCardProps {
nowMs: number;
/** Hides the progress bar when no wallet is connected. */
showProgress: boolean;
/** Lifecycle stage; drives the countdown label and claim controls (#1204). */
status?: CampaignStatus;
/** Claims the reward for an ended campaign; omit to hide the claim button. */
onClaim?: (campaignId: string) => void;
isClaiming?: boolean;
}

/** One seasonal campaign with criteria, reward pool, countdown and eligibility (#1226). */
function getTimeLabel(
campaign: Campaign,
status: CampaignStatus,
nowMs: number
): { label: string; value: string } {
if (status === 'upcoming') {
return {
label: 'Starts in',
value: formatSpotlightTimeRemaining(campaign.startsAt, nowMs) ?? 'Soon',
};
}
if (status === 'ended') return { label: 'Time left', value: 'Ended' };
return {
label: 'Time left',
value: formatSpotlightTimeRemaining(campaign.endsAt, nowMs) ?? 'Ended',
};
}

/** One seasonal campaign with criteria, reward pool, countdown and eligibility (#1226, #1204). */
export default function CampaignCard({
campaign,
nowMs,
showProgress,
status = 'active',
onClaim,
isClaiming = false,
}: CampaignCardProps) {
const timeRemaining =
formatSpotlightTimeRemaining(campaign.endsAt, nowMs) ?? 'Ended';
const time = getTimeLabel(campaign, status, nowMs);
const eligibility = getCampaignEligibility(
campaign.walletProgress,
campaign.criteria.target
);
const progressPercent = Math.round(eligibility.percent);
const isClaimed = showProgress && Boolean(campaign.claimed);
const showClaimButton =
showProgress && Boolean(onClaim) && canClaimCampaignReward(campaign, nowMs);

return (
<article
data-testid={`campaign-card-${campaign.id}`}
data-status={status}
aria-labelledby={`campaign-${campaign.id}-title`}
className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-slate-900/50 p-5 backdrop-blur-md"
>
Expand All @@ -52,14 +84,25 @@ export default function CampaignCard({
{campaign.name}
</h3>
</div>
{showProgress && eligibility.eligible && (
{isClaimed ? (
<span
data-testid="campaign-eligible-badge"
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-emerald-400/30 bg-emerald-400/15 px-2 py-0.5 text-[0.65rem] font-bold uppercase tracking-wide text-emerald-300"
data-testid="campaign-claimed-badge"
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-sky-400/30 bg-sky-400/15 px-2 py-0.5 text-[0.65rem] font-bold uppercase tracking-wide text-sky-300"
>
<BadgeCheck className="size-3.5" aria-hidden="true" />
Eligible
<CheckCircle2 className="size-3.5" aria-hidden="true" />
Claimed
</span>
) : (
showProgress &&
eligibility.eligible && (
<span
data-testid="campaign-eligible-badge"
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-emerald-400/30 bg-emerald-400/15 px-2 py-0.5 text-[0.65rem] font-bold uppercase tracking-wide text-emerald-300"
>
<BadgeCheck className="size-3.5" aria-hidden="true" />
Eligible
</span>
)
)}
</header>

Expand All @@ -86,15 +129,15 @@ export default function CampaignCard({
<div>
<dt className="flex items-center gap-1 font-semibold uppercase tracking-wider text-white/45">
<Clock className="size-3" aria-hidden="true" />
Time left
{time.label}
</dt>
<dd
role="timer"
aria-live="off"
data-testid="campaign-time-remaining"
className="mt-1 font-mono text-sm font-semibold text-amber-200"
>
{timeRemaining}
{time.value}
</dd>
</div>
</dl>
Expand Down Expand Up @@ -123,6 +166,18 @@ export default function CampaignCard({
/>
</div>
)}

{showClaimButton && (
<Button
type="button"
onClick={() => onClaim?.(campaign.id)}
disabled={isClaiming}
data-testid={`campaign-claim-${campaign.id}`}
className="w-full"
>
{isClaiming ? 'Claiming…' : 'Claim reward'}
</Button>
)}
</article>
);
}
53 changes: 52 additions & 1 deletion src/hooks/useCampaigns.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import type { Signer } from '@/lib/signing/types';
import { campaignContractService } from '@/services/campaignContract.service';
import { campaignsService } from '@/services/campaigns.service';
import type { Campaign } from '@/utils/campaign.utils';
import { getSignatureErrorMessage } from '@/utils/errorHandling.utils';
import showToast from '@/utils/toast.util';

// Campaign progress only moves on trades, so a minute keeps it fresh without
// competing with the discovery page's own 60s refresh.
Expand All @@ -14,3 +19,49 @@ export function useActiveCampaigns(wallet: string | undefined) {
refetchInterval: CAMPAIGNS_POLL_INTERVAL_MS,
});
}

/** Every seasonal campaign — upcoming, active and ended — for the campaigns page (#1204). */
export function useCampaigns(wallet: string | undefined) {
return useQuery({
queryKey: queryKeys.campaigns.list(wallet),
queryFn: () => campaignsService.getCampaigns(wallet),
refetchInterval: CAMPAIGNS_POLL_INTERVAL_MS,
});
}

/** Claims an ended campaign's reward via the on-chain `claim_campaign_reward` call (#1204). */
export function useClaimCampaignRewardMutation(
wallet: string,
signer: Signer | null
) {
const queryClient = useQueryClient();

return useMutation({
mutationKey: ['contract', 'claim_campaign_reward', wallet],
mutationFn: (campaignId: string) =>
campaignContractService.claimCampaignReward({
campaignId,
wallet,
signer,
}),
onError: error => {
showToast.error(getSignatureErrorMessage(error));
},
onSuccess: (_result, campaignId) => {
// Show the Claimed badge straight away; the refetch below confirms it.
queryClient.setQueryData<Campaign[]>(
queryKeys.campaigns.list(wallet),
campaigns =>
campaigns?.map(campaign =>
campaign.id === campaignId ? { ...campaign, claimed: true } : campaign
)
);
showToast.success('Campaign reward claimed');
},
onSettled: () => {
void queryClient.invalidateQueries({
queryKey: queryKeys.campaigns.all(),
});
},
});
}
14 changes: 14 additions & 0 deletions src/hooks/useKeyReserve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { keyReserveService } from '@/services/keyReserve.service';

/** Protocol reserve backing a creator key, refetched on every page load (#1208). */
export function useKeyReserve(keyId: string) {
return useQuery({
queryKey: queryKeys.creators.reserve(keyId),
queryFn: () => keyReserveService.getKeyReserve(keyId),
enabled: !!keyId,
refetchOnMount: 'always',
retry: false,
});
}
6 changes: 6 additions & 0 deletions src/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export const queryKeys = {
['creators', creatorId, 'tips'] as const,
uniqueTraders: (creatorId: string) =>
['creators', creatorId, 'unique-traders'] as const,
reserve: (creatorId: string) =>
['creators', creatorId, 'reserve'] as const,
curveConfig: (creatorId: string) =>
['creators', creatorId, 'curve-config'] as const,
curveMigrations: (creatorId: string) =>
Expand Down Expand Up @@ -96,6 +98,10 @@ export const queryKeys = {
['creators', 'discovery', 'campaigns', wallet ?? ''] as const,
},
},
campaigns: {
all: () => ['campaigns'] as const,
list: (wallet?: string) => ['campaigns', 'list', wallet ?? ''] as const,
},
wallet: {
holdings: (address: string) => ['wallet', address, 'holdings'] as const,
badges: (address: string) => ['wallet', address, 'badges'] as const,
Expand Down
Loading
Loading