diff --git a/src/app/explore/ExplorePageClient.tsx b/src/app/explore/ExplorePageClient.tsx index 5e15fe0..695f5e4 100644 --- a/src/app/explore/ExplorePageClient.tsx +++ b/src/app/explore/ExplorePageClient.tsx @@ -9,6 +9,7 @@ import { Footer } from "@/components/Footer"; import { IntentStatusBadge } from "@/components/IntentStatusBadge"; import { SkeletonCard } from "@/components/Skeleton"; import { useLiveIntents } from "@/hooks/useLiveIntents"; +import { useTranslation } from "@/lib/i18n/I18nProvider"; import { timeAgo } from "@/lib/time"; import { CHAINS } from "@/lib/marketData"; import { sanitizeDisplayText } from "@/lib/textSafety"; @@ -32,6 +33,7 @@ function readSort(value: string | null): SortOption { } export default function ExplorePageClient() { + const { t } = useTranslation(); const { intents, isLoading, error, isLive } = useLiveIntents(); const router = useRouter(); const pathname = usePathname(); @@ -55,6 +57,13 @@ export default function ExplorePageClient() { const setChainFilter = (value: string) => updateQuery({ chain: value }); const setSort = (value: SortOption) => updateQuery({ sort: value }); + const isFiltered = statusFilter !== "all" || chainFilter !== "all"; + + const clearFilters = () => { + setStatusFilter("all"); + setChainFilter("all"); + }; + const filtered = useMemo(() => { let result = intents; if (statusFilter !== "all") { @@ -102,7 +111,7 @@ export default function ExplorePageClient() {
@@ -149,7 +158,17 @@ export default function ExplorePageClient() { - + {isFiltered && ( + + )} + + {filtered.length} intent{filtered.length === 1 ? "" : "s"} @@ -158,12 +177,23 @@ export default function ExplorePageClient() { {isLoading && intents.length === 0 ? ( ) : error ? ( -
- Couldn't load intents right now. Try again shortly. +
+

{t("explore.error.title")}

+

{t("explore.error.message")}

) : filtered.length === 0 ? ( -
- No intents match your filters. +
+

{t("explore.empty.title")}

+

{t("explore.empty.message")}

+ {isFiltered && ( + + )}
) : (
{children} + diff --git a/src/app/my-intents/page.tsx b/src/app/my-intents/page.tsx index 1a83f0d..34bad57 100644 --- a/src/app/my-intents/page.tsx +++ b/src/app/my-intents/page.tsx @@ -41,6 +41,7 @@ function swapAgainHref(item: FeedItem): string { } export default function MyIntentsPage() { + const { t } = useTranslation(); const address = useWalletStore((s) => s.address); const isConnected = useWalletStore((s) => s.isConnected); @@ -54,6 +55,13 @@ export default function MyIntentsPage() { const { intent: expandedIntent, isLoading: expandedLoading, error: expandedError } = useIntent(expandedId); + const isFiltered = statusFilter !== "all" || chainFilter !== "all"; + + const clearFilters = () => { + setStatusFilter("all"); + setChainFilter("all"); + }; + const filtered = useMemo(() => { let result = intents; if (statusFilter !== "all") result = result.filter((i) => i.status === statusFilter); @@ -104,7 +112,7 @@ export default function MyIntentsPage() { {isConnected && (
)}
@@ -178,7 +186,7 @@ export default function MyIntentsPage() { Export CSV - + {filtered.length} intent{filtered.length === 1 ? "" : "s"} @@ -221,18 +229,37 @@ export default function MyIntentsPage() {
) : intents.length === 0 ? ( -
-

You haven't submitted any swaps yet.

+ /* Wallet is connected but no swaps have been submitted at all */ +
+

+ {t("myIntents.empty.title")} +

+

+ {t("myIntents.empty.message")} +

- Make your first swap + {t("myIntents.empty.cta")}
) : filtered.length === 0 ? ( -
- No intents match your filters. + /* Intents exist but the active filter combination matches nothing */ +
+

+ {t("myIntents.filterEmpty.title")} +

+

+ {t("myIntents.filterEmpty.message")} +

+
) : (
@@ -317,6 +344,33 @@ export default function MyIntentsPage() { })}
)} + + {/* Pagination */} + {pageCount > 1 && filtered.length > 0 && ( +
+ + + Page {page} of {pageCount} + + +
+ )} )} diff --git a/src/app/solve/[address]/page.tsx b/src/app/solve/[address]/page.tsx index d6dc4ac..5df6229 100644 --- a/src/app/solve/[address]/page.tsx +++ b/src/app/solve/[address]/page.tsx @@ -8,6 +8,7 @@ import { CopyButton } from "@/components/CopyButton"; import { SkeletonCard } from "@/components/Skeleton"; import { useSolver } from "@/hooks/useSolver"; import { useIntentFeed } from "@/hooks/useIntentFeed"; +import { useTranslation } from "@/lib/i18n/I18nProvider"; import { timeAgo } from "@/lib/time"; import { CHAINS } from "@/lib/marketData"; import { isValidStellarPublicKey } from "@/lib/stellarAddress"; @@ -20,6 +21,7 @@ const usdCompact = new Intl.NumberFormat("en-US", { }); export default function SolverDetailPage({ params }: { params: { address: string } }) { + const { t } = useTranslation(); const isValidAddress = isValidStellarPublicKey(params.address); const { solver, isLoading, error } = useSolver(isValidAddress ? params.address : null); const { items: fillHistory, isLoading: historyLoading, error: historyError } = useIntentFeed(); @@ -64,7 +66,7 @@ export default function SolverDetailPage({ params }: { params: { address: string {sanitizeDisplayText(solver.name)}
-
- {/* Fill history section */} + {/* ── Solver Timeline ─────────────────────────────────────────── */} +
+ +
+ + {/* ── Fill history table ──────────────────────────────────────── */}

Recent Fills by Solver

@@ -134,8 +145,13 @@ export default function SolverDetailPage({ params }: { params: { address: string Couldn't load fill history right now.
) : fillHistory.filter(item => item.solver === solver.address).length === 0 ? ( -
- No fills from this solver in the history. +
+

+ {t("solverDetail.fillHistory.empty.title")} +

+

+ {t("solverDetail.fillHistory.empty.message")} +

) : (
@@ -143,8 +159,8 @@ export default function SolverDetailPage({ params }: { params: { address: string .filter(item => item.solver === solver.address) .slice(0, 10) .map(fill => ( -
diff --git a/src/components/ActivityFeed.tsx b/src/components/ActivityFeed.tsx index b30e844..48b0342 100644 --- a/src/components/ActivityFeed.tsx +++ b/src/components/ActivityFeed.tsx @@ -9,8 +9,12 @@ import type { FeedItem } from "@/lib/types"; import { SkeletonCard } from "./Skeleton"; const CHAIN_COLOR: Record = { - ethereum: "#627EEA", base: "#0052FF", polygon: "#8247E5", - arbitrum: "#12AAFF", optimism: "#FF0420", avalanche: "#E84142", + ethereum: "#627EEA", + base: "#0052FF", + polygon: "#8247E5", + arbitrum: "#12AAFF", + optimism: "#FF0420", + avalanche: "#E84142", }; /** Maximum number of activity items shown in the feed. */ @@ -139,7 +143,6 @@ export function ActivityFeedView({ items, isLoading, error, isLive }: ActivityFe {timeAgo(item.createdAt)} diff --git a/src/components/ConnectivityBanner.test.tsx b/src/components/ConnectivityBanner.test.tsx new file mode 100644 index 0000000..3c0ebea --- /dev/null +++ b/src/components/ConnectivityBanner.test.tsx @@ -0,0 +1,100 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ConnectivityBanner } from "./ConnectivityBanner"; + +// Mock useConnectivity so we can control state without real events. +const mockConnectivity = vi.hoisted(() => ({ + connectivity: "online" as "online" | "offline", + reconnectionCount: 0, +})); + +vi.mock("@/hooks/useConnectivity", () => ({ + useConnectivity: () => mockConnectivity, +})); + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe("ConnectivityBanner", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockConnectivity.connectivity = "online"; + mockConnectivity.reconnectionCount = 0; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders nothing when the user is online and no prior offline event occurred", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("shows an offline banner when connectivity transitions to offline", async () => { + mockConnectivity.connectivity = "offline"; + + render(); + + await waitFor(() => { + expect(screen.getByTestId("connectivity-banner")).toBeInTheDocument(); + }); + + expect(screen.getByText(/you appear to be offline/i)).toBeInTheDocument(); + expect(screen.getByTestId("connectivity-banner")).toHaveAttribute("data-connectivity", "offline"); + }); + + it("shows a 'back online' message when connectivity is restored", async () => { + // Start offline. + mockConnectivity.connectivity = "offline"; + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByTestId("connectivity-banner")).toBeInTheDocument(); + }); + + // Regain connectivity. + mockConnectivity.connectivity = "online"; + rerender(); + + await waitFor(() => { + expect(screen.getByTestId("connectivity-banner")).toHaveAttribute("data-connectivity", "online"); + }); + + expect(screen.getByText(/back online/i)).toBeInTheDocument(); + }); + + it("automatically dismisses the banner after the grace period on reconnection", async () => { + mockConnectivity.connectivity = "offline"; + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByTestId("connectivity-banner")).toBeInTheDocument(); + }); + + mockConnectivity.connectivity = "online"; + rerender(); + + // Banner should still be visible immediately after coming back online. + await waitFor(() => { + expect(screen.getByTestId("connectivity-banner")).toBeInTheDocument(); + }); + + // Fast-forward past the 2500ms auto-dismiss timer. + await act(async () => { + vi.advanceTimersByTime(3000); + }); + + expect(screen.queryByTestId("connectivity-banner")).not.toBeInTheDocument(); + }); + + it("has role=status and aria-live=polite for accessibility", async () => { + mockConnectivity.connectivity = "offline"; + render(); + + await waitFor(() => { + const banner = screen.getByTestId("connectivity-banner"); + expect(banner).toHaveAttribute("role", "status"); + expect(banner).toHaveAttribute("aria-live", "polite"); + }); + }); +}); diff --git a/src/components/ConnectivityBanner.tsx b/src/components/ConnectivityBanner.tsx new file mode 100644 index 0000000..23beaca --- /dev/null +++ b/src/components/ConnectivityBanner.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useConnectivity } from "@/hooks/useConnectivity"; + +/** + * App-wide offline/connectivity-loss banner. + * + * Mounts once (in layout.tsx alongside WalletHydrator / ToastViewport) and + * listens for browser online/offline events via `useConnectivity`. Shows a + * persistent, accessible banner while the user is offline and automatically + * dismisses it a moment after connectivity is restored. + * + * The banner is dismissed automatically on reconnect (after a brief grace + * period) and does NOT need a manual close button in the offline state — the + * act of coming back online is the dismissal signal. + */ +export function ConnectivityBanner() { + const { connectivity } = useConnectivity(); + const [visible, setVisible] = useState(false); + const dismissTimerRef = useRef | null>(null); + + useEffect(() => { + if (connectivity === "offline") { + // Cancel any pending dismissal — we've gone offline again. + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current); + dismissTimerRef.current = null; + } + setVisible(true); + } else { + // Give the user a moment to see the "back online" state before hiding. + dismissTimerRef.current = setTimeout(() => { + setVisible(false); + dismissTimerRef.current = null; + }, 2500); + } + + return () => { + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current); + dismissTimerRef.current = null; + } + }; + }, [connectivity]); + + if (!visible) return null; + + const isOffline = connectivity === "offline"; + + return ( +
+ {isOffline ? ( + <> + {/* Offline icon */} + + You appear to be offline — reconnecting… + + ) : ( + <> + {/* Back-online checkmark */} + + Back online — refreshing data… + + )} +
+ ); +} diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index a1aa3cf..442ec71 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -96,6 +96,7 @@ export function Nav(props: NavProps) {