diff --git a/src/components/common/ReserveBalanceChip.tsx b/src/components/common/ReserveBalanceChip.tsx new file mode 100755 index 00000000..cdc910c2 --- /dev/null +++ b/src/components/common/ReserveBalanceChip.tsx @@ -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 ( +
+ + {formatDisplayKeyPrice(reserve.reserveStroops)} + + + + + {isLow && ( + + + )} +
+ ); +} diff --git a/src/components/common/__tests__/ReserveBalanceChip.test.tsx b/src/components/common/__tests__/ReserveBalanceChip.test.tsx new file mode 100755 index 00000000..2d719e08 --- /dev/null +++ b/src/components/common/__tests__/ReserveBalanceChip.test.tsx @@ -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( + + ); + + 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( + + ); + + expect(screen.getByTestId('reserve-low-warning')).toHaveTextContent( + 'Low reserve' + ); + }); + + it('renders nothing for a key with zero reserve', () => { + const { container } = render( + + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/marketplace/CampaignCard.tsx b/src/components/marketplace/CampaignCard.tsx old mode 100644 new mode 100755 index 2809ec65..cbeabca2 --- a/src/components/marketplace/CampaignCard.tsx +++ b/src/components/marketplace/CampaignCard.tsx @@ -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'; @@ -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 (
@@ -52,14 +84,25 @@ export default function CampaignCard({ {campaign.name} - {showProgress && eligibility.eligible && ( + {isClaimed ? ( - + ) : ( + showProgress && + eligibility.eligible && ( + + + ) )} @@ -86,7 +129,7 @@ export default function CampaignCard({
- {timeRemaining} + {time.value}
@@ -123,6 +166,18 @@ export default function CampaignCard({ /> )} + + {showClaimButton && ( + + )}
); } diff --git a/src/hooks/useCampaigns.ts b/src/hooks/useCampaigns.ts index 46cf5b20..31d5fd63 100644 --- a/src/hooks/useCampaigns.ts +++ b/src/hooks/useCampaigns.ts @@ -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. @@ -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( + 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(), + }); + }, + }); +} diff --git a/src/hooks/useKeyReserve.ts b/src/hooks/useKeyReserve.ts new file mode 100755 index 00000000..4631a179 --- /dev/null +++ b/src/hooks/useKeyReserve.ts @@ -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, + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 35e74288..37a92c8f 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -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) => @@ -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, diff --git a/src/pages/CampaignsPage.tsx b/src/pages/CampaignsPage.tsx old mode 100644 new mode 100755 index 72b6617e..2019ee33 --- a/src/pages/CampaignsPage.tsx +++ b/src/pages/CampaignsPage.tsx @@ -1,20 +1,66 @@ +import { Networks } from '@stellar/stellar-sdk'; import { useAccount } from 'wagmi'; import CampaignCard from '@/components/marketplace/CampaignCard'; import EmptyState from '@/components/common/EmptyState'; import Skeleton from '@/components/ui/skeleton'; -import { useActiveCampaigns } from '@/hooks/useCampaigns'; +import { + useCampaigns, + useClaimCampaignRewardMutation, +} from '@/hooks/useCampaigns'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useNowMs } from '@/hooks/useNowMs'; -import { filterActiveCampaigns } from '@/utils/campaign.utils'; +import { useSigner } from '@/hooks/useSigner'; +import { + groupCampaignsByStatus, + type Campaign, + type CampaignStatus, +} from '@/utils/campaign.utils'; +import { env } from '@/utils/env.utils'; -/** Full list of active seasonal campaigns, linked from the discovery page (#1226). */ +const networkPassphrase = + env.VITE_STELLAR_NETWORK === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; + +const SECTIONS: ReadonlyArray<{ status: CampaignStatus; title: string }> = [ + { status: 'active', title: 'Active' }, + { status: 'upcoming', title: 'Upcoming' }, + { status: 'ended', title: 'Ended' }, +]; + +/** Seasonal campaigns grouped into active, upcoming and ended, with reward claims (#1226, #1204). */ export default function CampaignsPage() { useDocumentTitle('Campaigns — AccessLayer'); const { address } = useAccount(); - const { data, isLoading, isError, refetch } = useActiveCampaigns(address); + const wallet = address ?? ''; + const { data, isLoading, isError, refetch } = useCampaigns(address); + const { activeSigner } = useSigner(networkPassphrase); + const claimMutation = useClaimCampaignRewardMutation(wallet, activeSigner); const nowMs = useNowMs(); - const campaigns = filterActiveCampaigns(data ?? [], nowMs); + const groups = groupCampaignsByStatus(data ?? [], nowMs); + const hasCampaigns = + groups.active.length + groups.upcoming.length + groups.ended.length > 0; + const claimingId = claimMutation.isPending + ? claimMutation.variables + : undefined; + + const renderGrid = (status: CampaignStatus, campaigns: Campaign[]) => ( +
+ {campaigns.map(campaign => ( + claimMutation.mutate(campaignId)} + isClaiming={claimingId === campaign.id} + /> + ))} +
+ ); return (
@@ -25,7 +71,8 @@ export default function CampaignsPage() {

Seasonal reward campaigns for creator keys. Track how close your - wallet is to qualifying before each campaign ends. + wallet is to qualifying, and claim your reward once a campaign + ends.

@@ -38,29 +85,34 @@ export default function CampaignsPage() { ) : isError ? ( void refetch() }} className="mx-auto w-full max-w-xl" /> - ) : campaigns.length === 0 ? ( + ) : !hasCampaigns ? ( ) : ( -
- {campaigns.map(campaign => ( - - ))} +
+ {SECTIONS.filter(({ status }) => groups[status].length > 0).map( + ({ status, title }) => ( +
+

+ {title} +

+ {renderGrid(status, groups[status])} +
+ ) + )}
)}
diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 4e5de26c..c536f1d8 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -77,6 +77,9 @@ import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useKeyTwap, type TwapWindow } from '@/hooks/useKeyTwap'; import { useKeyStats } from '@/hooks/useKeyStats'; import { useKeyUniqueTraders } from '@/hooks/useKeyUniqueTraders'; +import { useKeyReserve } from '@/hooks/useKeyReserve'; +import ReserveBalanceChip from '@/components/common/ReserveBalanceChip'; +import { getKeyReserveStatus } from '@/utils/keyReserve.utils'; import { useKeyConfig } from '@/hooks/useKeyConfig'; import KeyStatsPanel from '@/components/common/KeyStatsPanel'; import Skeleton from '@/components/ui/skeleton'; @@ -221,6 +224,8 @@ function CreatorDetailPageContent() { const { data: uniqueTraders, isLoading: isUniqueTradersLoading } = useKeyUniqueTraders(id || ''); + const { data: keyReserve } = useKeyReserve(id || ''); + const { data: keyConfig, isLoading: isKeyConfigLoading } = useKeyConfig( id || '' ); @@ -521,6 +526,15 @@ function CreatorDetailPageContent() { : 10 ), }, + // Reserve backstop (#1208); hidden until the key has accrued a reserve. + ...(keyReserve && getKeyReserveStatus(keyReserve).visible + ? [ + { + label: 'Reserve Balance', + value: , + }, + ] + : []), ]; const chartData = ( diff --git a/src/pages/__tests__/CampaignsPage.test.tsx b/src/pages/__tests__/CampaignsPage.test.tsx new file mode 100755 index 00000000..5860375c --- /dev/null +++ b/src/pages/__tests__/CampaignsPage.test.tsx @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { useAccount } from 'wagmi'; +import CampaignsPage from '@/pages/CampaignsPage'; +import { + useCampaigns, + useClaimCampaignRewardMutation, +} from '@/hooks/useCampaigns'; +import type { Campaign } from '@/utils/campaign.utils'; + +vi.mock('@/hooks/useCampaigns', () => ({ + useCampaigns: vi.fn(), + useClaimCampaignRewardMutation: vi.fn(), +})); + +vi.mock('@/hooks/useSigner', () => ({ + useSigner: () => ({ activeSigner: null }), +})); + +const mockUseCampaigns = vi.mocked(useCampaigns); +const mockUseClaim = vi.mocked(useClaimCampaignRewardMutation); +const mockUseAccount = vi.mocked(useAccount); +const NOW = Date.parse('2026-10-20T12:00:00Z'); +const HOUR = 60 * 60 * 1000; +const WALLET = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; + +function makeCampaign(id: string, overrides: Partial = {}): Campaign { + return { + id, + name: `Season ${id}`, + creatorId: `creator-${id}`, + keyName: 'NOVA', + criteria: { type: 'trade_volume', target: 100 }, + rewardPoolXlm: 5000, + endsAt: new Date(NOW + HOUR).toISOString(), + walletProgress: 40, + ...overrides, + }; +} + +const mutate = vi.fn(); + +function setup(data: Campaign[] | undefined, address: string | undefined = WALLET) { + mockUseAccount.mockReturnValue({ + address, + isConnected: Boolean(address), + } as unknown as ReturnType); + mockUseCampaigns.mockReturnValue({ + data, + isLoading: false, + isError: false, + refetch: vi.fn(), + } as unknown as ReturnType); + mockUseClaim.mockReturnValue({ + mutate, + isPending: false, + variables: undefined, + } as unknown as ReturnType); + + return render( + + + + ); +} + +describe('CampaignsPage', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + mutate.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('groups campaigns into active, upcoming and ended sections', () => { + setup([ + makeCampaign('a'), + makeCampaign('u', { + startsAt: new Date(NOW + 2 * HOUR).toISOString(), + endsAt: new Date(NOW + 48 * HOUR).toISOString(), + }), + makeCampaign('e', { endsAt: new Date(NOW - HOUR).toISOString() }), + ]); + + expect( + within(screen.getByTestId('campaigns-active-grid')).getByTestId( + 'campaign-card-a' + ) + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('campaigns-upcoming-grid')).getByTestId( + 'campaign-card-u' + ) + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('campaigns-ended-grid')).getByTestId( + 'campaign-card-e' + ) + ).toBeInTheDocument(); + }); + + it('shows eligibility progress and a live countdown on active campaigns', () => { + setup([makeCampaign('a')]); + const card = screen.getByTestId('campaign-card-a'); + + expect(within(card).getByTestId('campaign-progress-label')).toHaveTextContent( + '40 / 100' + ); + expect(within(card).getByTestId('campaign-time-remaining')).toHaveTextContent( + '1h 0m 0s' + ); + + act(() => { + vi.advanceTimersByTime(5_000); + }); + + expect(within(card).getByTestId('campaign-time-remaining')).toHaveTextContent( + '59m 55s' + ); + }); + + it('submits the claim for an ended campaign the wallet qualified for', () => { + setup([ + makeCampaign('e', { + endsAt: new Date(NOW - HOUR).toISOString(), + walletProgress: 150, + }), + ]); + + fireEvent.click(screen.getByTestId('campaign-claim-e')); + + expect(mutate).toHaveBeenCalledWith('e'); + }); + + it('does not offer a claim to wallets that missed the criteria', () => { + setup([ + makeCampaign('e', { + endsAt: new Date(NOW - HOUR).toISOString(), + walletProgress: 10, + }), + ]); + + expect(screen.queryByTestId('campaign-claim-e')).not.toBeInTheDocument(); + }); + + it('shows the claimed badge instead of the claim button once claimed', () => { + setup([ + makeCampaign('e', { + endsAt: new Date(NOW - HOUR).toISOString(), + walletProgress: 150, + claimed: true, + }), + ]); + + expect(screen.getByTestId('campaign-claimed-badge')).toHaveTextContent( + 'Claimed' + ); + expect(screen.queryByTestId('campaign-claim-e')).not.toBeInTheDocument(); + }); + + it('shows the empty state when no campaigns exist', () => { + setup([]); + + expect(screen.getByText('No campaigns yet')).toBeInTheDocument(); + }); +}); diff --git a/src/services/campaignContract.service.ts b/src/services/campaignContract.service.ts new file mode 100755 index 00000000..ca65e346 --- /dev/null +++ b/src/services/campaignContract.service.ts @@ -0,0 +1,128 @@ +import { Address, Networks } from '@stellar/stellar-sdk'; +import { + Client, + type AssembledTransaction, +} from '@stellar/stellar-sdk/contract'; +import type { Signer } from '@/lib/signing/types'; +import { env } from '@/utils/env.utils'; + +export interface CampaignContractClient { + claim_campaign_reward(args: { + campaign_id: string; + claimant: Address; + }): Promise>; +} + +export type CampaignContractClientFactory = ( + address: string | undefined, + signer: Signer +) => Promise; + +export interface ClaimCampaignRewardInput { + /** Campaign id being claimed, forwarded to the contract call. */ + campaignId: string; + /** Connected Stellar wallet address of the claimant. */ + wallet: string; + /** Signer used to sign and submit the transaction. */ + signer: Signer | null; +} + +export interface ClaimCampaignRewardResult { + hash: string | null; +} + +export class CampaignContractError extends Error { + constructor(message: string) { + super(message); + this.name = 'CampaignContractError'; + } +} + +function getNetworkPassphrase(): string { + return env.VITE_STELLAR_NETWORK === 'mainnet' + ? Networks.PUBLIC + : Networks.TESTNET; +} + +function parseAddress(value: string | undefined, label: string): Address { + if (!value) { + throw new CampaignContractError(`${label} is not configured.`); + } + + try { + return Address.fromString(value); + } catch { + throw new CampaignContractError( + `${label} is not a valid Stellar address.` + ); + } +} + +const defaultFactory: CampaignContractClientFactory = async ( + address, + signer +) => { + if (!env.VITE_STELLAR_RPC_URL) { + throw new CampaignContractError( + 'VITE_STELLAR_RPC_URL is required for campaign contract calls.' + ); + } + if (!env.VITE_CREATOR_KEYS_CONTRACT_ID) { + throw new CampaignContractError( + 'VITE_CREATOR_KEYS_CONTRACT_ID is required for campaign contract calls.' + ); + } + + const publicKey = address ?? (await signer.getPublicKey()); + + return Client.from({ + contractId: env.VITE_CREATOR_KEYS_CONTRACT_ID, + rpcUrl: env.VITE_STELLAR_RPC_URL, + networkPassphrase: getNetworkPassphrase(), + signTransaction: async (transaction: string) => ({ + signedTxXdr: await signer.sign(transaction), + }), + ...(publicKey ? { publicKey } : {}), + }); +}; + +export class CampaignContractService { + private readonly clientFactory: CampaignContractClientFactory; + + constructor(clientFactory: CampaignContractClientFactory = defaultFactory) { + this.clientFactory = clientFactory; + } + + /** + * Signs and submits the on-chain `claim_campaign_reward` call for an + * ended campaign the wallet qualified for (#1204). + */ + async claimCampaignReward({ + campaignId, + wallet, + signer, + }: ClaimCampaignRewardInput): Promise { + if (!campaignId) { + throw new CampaignContractError('A campaign ID is required to claim.'); + } + if (!signer) { + throw new CampaignContractError( + 'A connected wallet signer is required to claim campaign rewards.' + ); + } + + const claimant = parseAddress(wallet, 'Connected Stellar address'); + const client = await this.clientFactory(wallet, signer); + const transaction = await client.claim_campaign_reward({ + campaign_id: campaignId, + claimant, + }); + const result = await transaction.signAndSend(); + + return { + hash: result.sendTransactionResponse?.hash ?? null, + }; + } +} + +export const campaignContractService = new CampaignContractService(); diff --git a/src/services/campaigns.service.ts b/src/services/campaigns.service.ts index 3c1f84a3..06616046 100644 --- a/src/services/campaigns.service.ts +++ b/src/services/campaigns.service.ts @@ -23,6 +23,27 @@ class CampaignsService extends BaseApiService { throw this.handleError(error); } } + + /** + * Every campaign — upcoming, active and ended — with the wallet's progress + * and claim status when supplied (#1204). + * GET /campaigns?wallet= + */ + async getCampaigns(wallet?: string): Promise { + try { + const response = await this.api.get>( + '/campaigns', + { params: wallet ? { wallet } : undefined } + ); + + const payload = response.data.data; + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload?.campaigns)) return payload.campaigns; + return []; + } catch (error) { + throw this.handleError(error); + } + } } export const campaignsService = new CampaignsService(); diff --git a/src/services/keyReserve.service.ts b/src/services/keyReserve.service.ts new file mode 100755 index 00000000..7e5302ff --- /dev/null +++ b/src/services/keyReserve.service.ts @@ -0,0 +1,22 @@ +import { BaseApiService, type APIResponse } from './api.service'; +import type { KeyReserve } from '@/utils/keyReserve.utils'; + +class KeyReserveService extends BaseApiService { + /** + * Protocol reserve balance backing a creator key (#1208). + * GET /keys/:key_id/reserve + */ + async getKeyReserve(keyId: string): Promise { + try { + const response = await this.api.get>( + `/keys/${keyId}/reserve` + ); + + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const keyReserveService = new KeyReserveService(); diff --git a/src/utils/__tests__/campaign.utils.test.ts b/src/utils/__tests__/campaign.utils.test.ts index 9bab8ed9..a50717a5 100644 --- a/src/utils/__tests__/campaign.utils.test.ts +++ b/src/utils/__tests__/campaign.utils.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; import { + canClaimCampaignReward, describeCampaignCriteria, filterActiveCampaigns, getCampaignEligibility, + getCampaignStatus, + groupCampaignsByStatus, type Campaign, } from '@/utils/campaign.utils'; @@ -78,3 +81,66 @@ describe('filterActiveCampaigns', () => { expect(result.map(c => c.id)).toEqual(['live']); }); }); + +describe('getCampaignStatus', () => { + const now = Date.parse('2026-10-20T12:00:00Z'); + + it('classifies upcoming, active and ended campaigns', () => { + expect( + getCampaignStatus( + { startsAt: '2026-10-21T00:00:00Z', endsAt: '2026-10-30T00:00:00Z' }, + now + ) + ).toBe('upcoming'); + expect(getCampaignStatus({ endsAt: '2026-10-21T00:00:00Z' }, now)).toBe( + 'active' + ); + expect(getCampaignStatus({ endsAt: '2026-10-19T00:00:00Z' }, now)).toBe( + 'ended' + ); + expect(getCampaignStatus({ endsAt: 'not-a-date' }, now)).toBe('ended'); + }); +}); + +describe('groupCampaignsByStatus', () => { + it('orders active by soonest end and ended by most recent', () => { + const now = Date.parse('2026-10-20T12:00:00Z'); + const groups = groupCampaignsByStatus( + [ + makeCampaign('late', '2026-10-25T00:00:00Z'), + makeCampaign('soon', '2026-10-21T00:00:00Z'), + makeCampaign('old', '2026-10-01T00:00:00Z'), + makeCampaign('recent', '2026-10-19T00:00:00Z'), + ], + now + ); + + expect(groups.active.map(c => c.id)).toEqual(['soon', 'late']); + expect(groups.ended.map(c => c.id)).toEqual(['recent', 'old']); + expect(groups.upcoming).toEqual([]); + }); +}); + +describe('canClaimCampaignReward', () => { + const now = Date.parse('2026-10-20T12:00:00Z'); + const ended = makeCampaign('e', '2026-10-19T00:00:00Z'); + + it('allows eligible unclaimed wallets on ended campaigns only', () => { + expect(canClaimCampaignReward({ ...ended, walletProgress: 5 }, now)).toBe( + true + ); + expect( + canClaimCampaignReward({ ...ended, walletProgress: 5, claimed: true }, now) + ).toBe(false); + expect(canClaimCampaignReward({ ...ended, walletProgress: 4 }, now)).toBe( + false + ); + expect(canClaimCampaignReward(ended, now)).toBe(false); + expect( + canClaimCampaignReward( + { ...makeCampaign('a', '2026-10-25T00:00:00Z'), walletProgress: 5 }, + now + ) + ).toBe(false); + }); +}); diff --git a/src/utils/__tests__/keyReserve.utils.test.ts b/src/utils/__tests__/keyReserve.utils.test.ts new file mode 100755 index 00000000..0ae743f0 --- /dev/null +++ b/src/utils/__tests__/keyReserve.utils.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { getKeyReserveStatus } from '@/utils/keyReserve.utils'; + +describe('getKeyReserveStatus', () => { + it('hides the chip when no reserve has accrued', () => { + expect(getKeyReserveStatus(null).visible).toBe(false); + expect( + getKeyReserveStatus({ reserveStroops: 0, marketCapStroops: 1_000 }) + .visible + ).toBe(false); + }); + + it('warns when the reserve is below 10% of market cap', () => { + expect( + getKeyReserveStatus({ reserveStroops: 99, marketCapStroops: 1_000 }) + ).toEqual({ visible: true, isLow: true, ratio: 0.099 }); + }); + + it('does not warn at or above the 10% threshold', () => { + expect( + getKeyReserveStatus({ reserveStroops: 100, marketCapStroops: 1_000 }) + .isLow + ).toBe(false); + expect( + getKeyReserveStatus({ reserveStroops: 500, marketCapStroops: 1_000 }) + .isLow + ).toBe(false); + }); + + it('shows the balance without a warning when market cap is unknown', () => { + expect( + getKeyReserveStatus({ reserveStroops: 100, marketCapStroops: null }) + ).toEqual({ visible: true, isLow: false, ratio: null }); + }); +}); diff --git a/src/utils/campaign.utils.ts b/src/utils/campaign.utils.ts index 66fee83c..e62af08b 100644 --- a/src/utils/campaign.utils.ts +++ b/src/utils/campaign.utils.ts @@ -16,10 +16,14 @@ export interface Campaign { criteria: CampaignCriteria; /** Total reward pool in XLM. */ rewardPoolXlm: number; + /** ISO timestamp when the campaign opens; absent once it has started. */ + startsAt?: string | null; /** ISO timestamp when the campaign closes. */ endsAt: string; /** Connected wallet's progress toward `criteria.target`; absent when signed out. */ walletProgress?: number | null; + /** Whether the connected wallet already claimed this campaign's reward. */ + claimed?: boolean; } export interface CampaignEligibility { @@ -74,3 +78,62 @@ export function filterActiveCampaigns( return Number.isFinite(endsAtMs) && endsAtMs > nowMs; }); } + +export type CampaignStatus = 'upcoming' | 'active' | 'ended'; + +export interface CampaignsByStatus { + active: Campaign[]; + upcoming: Campaign[]; + ended: Campaign[]; +} + +function parseTimeMs(value: string | null | undefined): number | null { + if (!value) return null; + const ms = new Date(value).getTime(); + return Number.isFinite(ms) ? ms : null; +} + +/** Where a campaign sits relative to `nowMs`; unparseable end times count as ended (#1204). */ +export function getCampaignStatus( + campaign: Pick, + nowMs: number +): CampaignStatus { + const startsAtMs = parseTimeMs(campaign.startsAt); + if (startsAtMs != null && startsAtMs > nowMs) return 'upcoming'; + const endsAtMs = parseTimeMs(campaign.endsAt); + if (endsAtMs == null || endsAtMs <= nowMs) return 'ended'; + return 'active'; +} + +/** + * Splits campaigns into active, upcoming and ended lists (#1204). Active and + * upcoming campaigns are ordered by the next deadline; ended ones show the + * most recently closed first. + */ +export function groupCampaignsByStatus( + campaigns: readonly Campaign[], + nowMs: number +): CampaignsByStatus { + const groups: CampaignsByStatus = { active: [], upcoming: [], ended: [] }; + for (const campaign of campaigns) { + groups[getCampaignStatus(campaign, nowMs)].push(campaign); + } + const endsAt = (campaign: Campaign) => parseTimeMs(campaign.endsAt) ?? 0; + const startsAt = (campaign: Campaign) => parseTimeMs(campaign.startsAt) ?? 0; + groups.active.sort((a, b) => endsAt(a) - endsAt(b)); + groups.upcoming.sort((a, b) => startsAt(a) - startsAt(b)); + groups.ended.sort((a, b) => endsAt(b) - endsAt(a)); + return groups; +} + +/** An ended campaign the wallet qualified for and has not claimed yet (#1204). */ +export function canClaimCampaignReward( + campaign: Campaign, + nowMs: number +): boolean { + if (campaign.claimed) return false; + if (getCampaignStatus(campaign, nowMs) !== 'ended') return false; + if (campaign.walletProgress == null) return false; + return getCampaignEligibility(campaign.walletProgress, campaign.criteria.target) + .eligible; +} diff --git a/src/utils/keyReserve.utils.ts b/src/utils/keyReserve.utils.ts new file mode 100755 index 00000000..2e52e5b6 --- /dev/null +++ b/src/utils/keyReserve.utils.ts @@ -0,0 +1,44 @@ +/** Reserve backing a creator key, as returned by GET /keys/:key_id/reserve (#1208). */ +export interface KeyReserve { + /** Protocol reserve accrued for the key, in stroops. */ + reserveStroops: number; + /** Current market cap of the key, in stroops. Null when unknown. */ + marketCapStroops: number | null; +} + +/** Reserves below this share of market cap are flagged as low (#1208). */ +export const LOW_RESERVE_RATIO = 0.1; + +export const RESERVE_TOOLTIP_TEXT = + 'The protocol reserve is a liquidity backstop funded by trading fees. It backs sells on the bonding curve and helps keep the key price stable.'; + +export interface KeyReserveStatus { + /** False for keys that have not accrued any reserve yet. */ + visible: boolean; + /** True when the reserve is below LOW_RESERVE_RATIO of market cap. */ + isLow: boolean; + /** Reserve as a share of market cap, or null when market cap is unknown. */ + ratio: number | null; +} + +/** Decides whether to show the reserve chip and whether to warn (#1208). */ +export function getKeyReserveStatus( + reserve: KeyReserve | null | undefined +): KeyReserveStatus { + const reserveStroops = reserve?.reserveStroops; + if ( + reserveStroops == null || + !Number.isFinite(reserveStroops) || + reserveStroops <= 0 + ) { + return { visible: false, isLow: false, ratio: null }; + } + + const marketCap = reserve?.marketCapStroops; + if (marketCap == null || !Number.isFinite(marketCap) || marketCap <= 0) { + return { visible: true, isLow: false, ratio: null }; + } + + const ratio = reserveStroops / marketCap; + return { visible: true, isLow: ratio < LOW_RESERVE_RATIO, ratio }; +}