From 359fe61a5446b65f9c4f40d15503c23e5a91d201 Mon Sep 17 00:00:00 2001 From: Aycode Date: Sun, 28 Jun 2026 04:52:37 -0700 Subject: [PATCH 1/3] feat(wallet-ux): rebrand connect page to match navbar flow --- package.json | 1 + src/components/CountdownTimer.tsx | 38 ++++++++++++++++++++++++------- src/components/RoundCard.tsx | 9 ++++---- src/hooks/useRoundCountdown.ts | 2 +- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 03a0131..a3891e5 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@vitest/ui": "^4.0.18", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", + "@testing-library/react-hooks": "^8.0.0", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "jsdom": "^24.0.0", diff --git a/src/components/CountdownTimer.tsx b/src/components/CountdownTimer.tsx index ca1fe63..960a3c3 100644 --- a/src/components/CountdownTimer.tsx +++ b/src/components/CountdownTimer.tsx @@ -1,28 +1,50 @@ -import { useRoundCountdown } from "../hooks/useRoundCountdown"; +import { useEffect } from 'react'; +import { useRoundCountdown } from '../hooks/useRoundCountdown'; interface CountdownTimerProps { - endTime: string | number | Date; + /** End time as ISO string, timestamp, or Date. Optional when `initialSeconds` is provided. */ + endTime?: string | number | Date; + /** Number of seconds from now to count down. If provided, `endTime` is ignored. */ + initialSeconds?: number; + /** Optional CSS class */ className?: string; + /** Callback invoked once when the timer reaches zero */ + onExpire?: () => void; } /** - * CountdownTimer displays a formatted time remaining until `endTime`. + * CountdownTimer displays a formatted time remaining until `endTime` or `initialSeconds`. * It uses the `useRoundCountdown` hook to manage the interval. */ -export default function CountdownTimer({ endTime, className = "" }: CountdownTimerProps) { - const { formattedTime, isExpired, timeLeftMs } = useRoundCountdown(endTime); +export default function CountdownTimer({ + endTime, + initialSeconds, + className = '', + onExpire, +}: CountdownTimerProps) { + const target = typeof initialSeconds === 'number' + ? Date.now() + initialSeconds * 1000 + : endTime; + + const { formattedTime, isExpired, timeLeftMs } = useRoundCountdown(target); + + // Trigger onExpire once when timer finishes + useEffect(() => { + if (isExpired && onExpire) { + onExpire(); + } + }, [isExpired, onExpire]); - // Urgent style when less than 2 minutes remain const isUrgent = !isExpired && timeLeftMs > 0 && timeLeftMs < 120_000; return ( - {isExpired ? "Ended" : formattedTime} + {isExpired ? 'Ended' : formattedTime} ); } diff --git a/src/components/RoundCard.tsx b/src/components/RoundCard.tsx index 8dc603f..0d82141 100644 --- a/src/components/RoundCard.tsx +++ b/src/components/RoundCard.tsx @@ -2,7 +2,7 @@ // ISSUE: Real-time round updates via Soroban event polling import type { MockRound } from '../types'; -import { useState } from "react"; +import { useEffect, useMemo } from "react"; import CountdownTimer from './CountdownTimer'; @@ -33,7 +33,7 @@ function poolSize(round: MockRound): number { export default function RoundCard({ round, onSubmitPrediction }: RoundCardProps) { - const [endTime] = useState(() => Date.now() + round.closesInSeconds * 1000); + const endTime = useMemo(() => Date.now() + round.closesIn * 1000, [round.closesIn]); const total = poolSize(round); const upRatio = round.mode === 'updown' && total > 0 ? (round.poolUp ?? 0) / total : 0; @@ -50,11 +50,10 @@ export default function RoundCard({ round, onSubmitPrediction }: RoundCardProps) subtitle={`Reference ${round.startPrice.toLocaleString()}`} actions={ {round.mode === "updown" ? "UP/DOWN" : "PRECISION"} diff --git a/src/hooks/useRoundCountdown.ts b/src/hooks/useRoundCountdown.ts index 36d2a05..830ff6c 100644 --- a/src/hooks/useRoundCountdown.ts +++ b/src/hooks/useRoundCountdown.ts @@ -35,7 +35,7 @@ export function useRoundCountdown( if (h > 0) { return `${pad(h)}:${pad(m)}:${pad(s)}`; } - // When less than an hour, omit hour component and avoid leading zero on minutes + // When less than an hour, omit hour component. return `${m}:${pad(s)}`; }; From 930dba2b68a10ab327e23ffc35ff6119518dbca0 Mon Sep 17 00:00:00 2001 From: sulaimonifeoluwa4-blip Date: Wed, 1 Jul 2026 14:22:30 +0000 Subject: [PATCH 2/3] Fix npm ci dependency conflict --- package.json | 1 - src/components/RoundCard.tsx | 61 +++++++++++-------- src/components/StatsCard.tsx | 1 + src/hooks/__tests__/useRoundCountdown.test.ts | 33 +++------- 4 files changed, 47 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index fc9a93f..ba72c04 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "@vitest/ui": "^4.0.18", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", - "@testing-library/react-hooks": "^8.0.0", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "jsdom": "^24.0.0", diff --git a/src/components/RoundCard.tsx b/src/components/RoundCard.tsx index d82957a..8fb75f0 100644 --- a/src/components/RoundCard.tsx +++ b/src/components/RoundCard.tsx @@ -1,17 +1,18 @@ - // ISSUE: Wire place_bet() to Xelma TypeScript bindings (xelma-contract) // ISSUE: Real-time round updates via Soroban event polling import { useEffect, useRef, useState } from 'react'; import type { MockRound } from '../types'; -import { useState } from "react"; - - import CountdownTimer from './CountdownTimer'; -import PanelHeader from './PanelHeader'; import { formatVXLM, formatPercent } from '../lib/utils'; import { TRANSITION } from '../utils/motion'; +const ASSET_ICONS: Record = { + BTC: '₿', + ETH: 'Ξ', + XLM: '✦', +}; + interface RoundCardProps { round: MockRound; onSubmitPrediction: (round: MockRound) => void; @@ -35,29 +36,25 @@ function poolSize(round: MockRound): number { } export default function RoundCard({ round, onSubmitPrediction }: RoundCardProps) { - - const [endTime] = useState(() => Date.now() + round.closesInSeconds * 1000); + const [endTime, setEndTime] = useState(() => new Date(Date.now() + round.closesInSeconds * 1000)); const total = poolSize(round); - const upRatio = round.mode === 'updown' && total > 0 ? (round.poolUp ?? 0) / total : 0; const upPct = Math.round(upRatio * 100); const downPct = round.mode === 'updown' ? 100 - upPct : 0; - const [endTime] = useState(() => new Date(Date.now() + round.closesInSeconds * 1000)); const statusMeta = getStatusMeta(round, round.closesInSeconds); const prevStatus = useRef(statusMeta.label); const [statusAnnouncement, setStatusAnnouncement] = useState(''); - const [endTime, setEndTime] = useState(() => new Date(Date.now() + round.closesInSeconds * 1000)); useEffect(() => { - const timer = setTimeout(() => { + const timer = window.setTimeout(() => { setEndTime(new Date(Date.now() + round.closesInSeconds * 1000)); }, 0); - return () => clearTimeout(timer); + return () => window.clearTimeout(timer); }, [round.closesInSeconds]); useEffect(() => { - const timer = setTimeout(() => { + const timer = window.setTimeout(() => { if (round.closesInSeconds <= 0) { setStatusAnnouncement('Round has ended'); } else if (prevStatus.current !== statusMeta.label) { @@ -65,30 +62,44 @@ export default function RoundCard({ round, onSubmitPrediction }: RoundCardProps) prevStatus.current = statusMeta.label; } }, 0); - return () => clearTimeout(timer); - }, [statusMeta.label, round.closesInSeconds]); + return () => window.clearTimeout(timer); + }, [round.closesInSeconds, statusMeta.label]); return (
{statusAnnouncement}
+
- {round.mode === "updown" ? "UP/DOWN" : "PRECISION"} + {ASSET_ICONS[round.asset]} - } - /> +
+

{round.asset}/USD

+

+ Reference ${round.startPrice.toLocaleString()} +

+
+
+ + + {round.mode === 'updown' ? 'UP/DOWN' : 'PRECISION'} + +
onSubmitPrediction(round)} - className="btn-primary mt-2 flex min-h-[44px] w-full items-center justify-center rounded-xl px-4 py-3 text-sm font-bold disabled:opacity-50 disabled:cursor-not-allowed" + className="btn-primary mt-2 flex min-h-[44px] w-full items-center justify-center rounded-xl px-4 py-3 text-sm font-bold disabled:cursor-not-allowed disabled:opacity-50" data-testid="round-card-submit" > Submit Prediction diff --git a/src/components/StatsCard.tsx b/src/components/StatsCard.tsx index 3975b72..1f27a6e 100644 --- a/src/components/StatsCard.tsx +++ b/src/components/StatsCard.tsx @@ -7,6 +7,7 @@ import { claim_winnings } from '../lib/xelma-contract'; import { toast } from 'sonner'; import { formatVXLM } from '../lib/utils'; import RankProgressBar from './RankProgressBar'; +import PanelHeader from './PanelHeader'; interface StatsCardProps { stats: MockUserStats; diff --git a/src/hooks/__tests__/useRoundCountdown.test.ts b/src/hooks/__tests__/useRoundCountdown.test.ts index f251038..2ecfd48 100644 --- a/src/hooks/__tests__/useRoundCountdown.test.ts +++ b/src/hooks/__tests__/useRoundCountdown.test.ts @@ -1,5 +1,5 @@ -import { renderHook, act } from '@testing-library/react'; -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useRoundCountdown } from '../../hooks/useRoundCountdown'; describe('useRoundCountdown Hook', () => { @@ -9,55 +9,42 @@ describe('useRoundCountdown Hook', () => { }); afterEach(() => { -import { renderHook, act } from "@testing-library/react"; -import { describe, test, expect, beforeAll, afterAll, vi } from "vitest"; -import { useRoundCountdown } from '../../hooks/useRoundCountdown'; - -// Helper to advance timers safely -function advance(ms: number) { - act(() => { - vi.advanceTimersByTime(ms); - }); -} - -describe('useRoundCountdown Hook', () => { - beforeAll(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-06-27T12:00:00Z')); // fixed now - }); - - afterAll(() => { vi.useRealTimers(); }); - it('Expired context returns isExpired true and 00:00', () => { + it('returns 00:00 and expires for past times', () => { const past = new Date('2026-06-27T11:00:00Z'); const { result } = renderHook(() => useRoundCountdown(past)); + expect(result.current.isExpired).toBe(true); expect(result.current.formattedTime).toBe('00:00'); expect(result.current.timeLeftMs).toBe(0); }); - it('Sub‑minute context shows mm:ss format', () => { + it('shows mm:ss formatting for sub-minute countdowns', () => { const future = new Date(Date.now() + 30 * 1000); const { result } = renderHook(() => useRoundCountdown(future)); + expect(result.current.isExpired).toBe(false); expect(result.current.formattedTime).toBe('0:30'); act(() => { vi.advanceTimersByTime(10 * 1000); }); + expect(result.current.formattedTime).toBe('0:20'); }); - it('Multi‑hour context formats HH:MM:SS', () => { + it('formats multi-hour countdowns as HH:MM:SS', () => { const future = new Date(Date.now() + (1 * 3600 + 2 * 60 + 3) * 1000); const { result } = renderHook(() => useRoundCountdown(future)); + expect(result.current.formattedTime).toBe('01:02:03'); act(() => { vi.advanceTimersByTime(62 * 1000); }); + expect(result.current.formattedTime).toBe('01:01:01'); }); }); From ee215b75b10482b0426a0d0a13e53ee5c21c6903 Mon Sep 17 00:00:00 2001 From: sulaimonifeoluwa4-blip Date: Wed, 1 Jul 2026 14:45:24 +0000 Subject: [PATCH 3/3] Fix npm ci and align test expectations --- src/hooks/__tests__/useRoundCountdown.test.ts | 4 ++-- src/hooks/useRoundCountdown.ts | 4 ++-- src/pages/Dashboard.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/hooks/__tests__/useRoundCountdown.test.ts b/src/hooks/__tests__/useRoundCountdown.test.ts index 2ecfd48..9750914 100644 --- a/src/hooks/__tests__/useRoundCountdown.test.ts +++ b/src/hooks/__tests__/useRoundCountdown.test.ts @@ -26,13 +26,13 @@ describe('useRoundCountdown Hook', () => { const { result } = renderHook(() => useRoundCountdown(future)); expect(result.current.isExpired).toBe(false); - expect(result.current.formattedTime).toBe('0:30'); + expect(result.current.formattedTime).toBe('00:30'); act(() => { vi.advanceTimersByTime(10 * 1000); }); - expect(result.current.formattedTime).toBe('0:20'); + expect(result.current.formattedTime).toBe('00:20'); }); it('formats multi-hour countdowns as HH:MM:SS', () => { diff --git a/src/hooks/useRoundCountdown.ts b/src/hooks/useRoundCountdown.ts index 3776c9b..b691883 100644 --- a/src/hooks/useRoundCountdown.ts +++ b/src/hooks/useRoundCountdown.ts @@ -35,8 +35,8 @@ export function useRoundCountdown( if (h > 0) { return `${pad(h)}:${pad(m)}:${pad(s)}`; } - // When less than an hour, omit hour component and do not pad minutes. - return `${m}:${pad(s)}`; + // When less than an hour, show a zero-padded minute value. + return `${pad(m)}:${pad(s)}`; }; useEffect(() => { diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index a12997f..226bb11 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -171,8 +171,8 @@ const Dashboard = () => { {!isLoading && !isRoundActive && (