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
42 changes: 36 additions & 6 deletions src/app/explore/ExplorePageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand All @@ -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") {
Expand Down Expand Up @@ -102,7 +111,7 @@ export default function ExplorePageClient() {
</div>
<div className="flex items-center gap-1.5 text-[10px] text-vx-muted px-1 pt-1 flex-shrink-0">
<span aria-hidden="true" className={`state-dot ${isLive ? "bg-vx-sage" : "bg-vx-dim"}`} />
{isLive ? "Live" : "Polling"}
{isLive ? t("activityFeed.status.live") : t("activityFeed.status.polling")}
</div>
</div>

Expand Down Expand Up @@ -149,7 +158,17 @@ export default function ExplorePageClient() {
<option value="largest">Largest amount</option>
</select>

<span className="text-xs text-vx-muted ml-auto">
{isFiltered && (
<button
type="button"
onClick={clearFilters}
className="px-3 py-2 rounded-lg border border-vx-sage/40 text-xs font-semibold text-vx-sage hover:border-vx-sage/70 transition-colors focus:outline-none focus:ring-2 focus:ring-vx-sage focus:ring-offset-2 focus:ring-offset-vx-ink"
>
{t("explore.empty.clearFilters")}
</button>
)}

<span className="text-xs text-vx-muted ml-auto" aria-live="polite" aria-atomic="true">
{filtered.length} intent{filtered.length === 1 ? "" : "s"}
</span>
</div>
Expand All @@ -158,12 +177,23 @@ export default function ExplorePageClient() {
{isLoading && intents.length === 0 ? (
<SkeletonCard rows={4} rowHeight="h-14" />
) : error ? (
<div className="card p-8 text-center text-sm text-vx-muted">
Couldn&apos;t load intents right now. Try again shortly.
<div className="card p-8 text-center">
<p className="text-sm font-medium text-vx-text mb-1">{t("explore.error.title")}</p>
<p className="text-xs text-vx-muted max-w-xs mx-auto">{t("explore.error.message")}</p>
</div>
) : filtered.length === 0 ? (
<div className="card p-8 text-center text-sm text-vx-muted">
No intents match your filters.
<div className="card p-8 text-center">
<p className="text-sm font-medium text-vx-text mb-1">{t("explore.empty.title")}</p>
<p className="text-xs text-vx-muted max-w-xs mx-auto mb-4">{t("explore.empty.message")}</p>
{isFiltered && (
<button
type="button"
onClick={clearFilters}
className="inline-block px-4 py-2 rounded-lg border border-vx-sage/40 text-vx-text text-sm hover:border-vx-sage/70 transition-colors focus:outline-none focus:ring-2 focus:ring-vx-sage focus:ring-offset-2 focus:ring-offset-vx-ink"
>
{t("explore.empty.clearFilters")}
</button>
)}
</div>
) : (
<div
Expand Down
2 changes: 1 addition & 1 deletion src/app/explore/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"use client";
import dynamic from "next/dynamic";

import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
Expand Down
1 change: 1 addition & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<IntentStatusWatcher />
{children}
<ToastViewport />
<ConnectivityBanner />
</I18nProvider>
</body>
</html>
Expand Down
70 changes: 62 additions & 8 deletions src/app/my-intents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
Expand Down Expand Up @@ -104,7 +112,7 @@ export default function MyIntentsPage() {
{isConnected && (
<div className="flex items-center gap-1.5 text-[10px] text-vx-muted px-1 pt-1 flex-shrink-0">
<span aria-hidden="true" className={`state-dot ${isLive ? "bg-vx-sage" : "bg-vx-dim"}`} />
{isLive ? "Live" : "Polling"}
{isLive ? t("activityFeed.status.live") : t("activityFeed.status.polling")}
</div>
)}
</div>
Expand Down Expand Up @@ -178,7 +186,7 @@ export default function MyIntentsPage() {
Export CSV
</button>

<span className="text-xs text-vx-muted" aria-live="polite" aria-atomic="true">
<span className="text-xs text-vx-muted ml-auto" aria-live="polite" aria-atomic="true">
{filtered.length} intent{filtered.length === 1 ? "" : "s"}
</span>
</fieldset>
Expand Down Expand Up @@ -221,18 +229,37 @@ export default function MyIntentsPage() {
</button>
</div>
) : intents.length === 0 ? (
<div role="status" className="card p-8 text-center text-sm text-vx-muted">
<p className="mb-4">You haven&apos;t submitted any swaps yet.</p>
/* Wallet is connected but no swaps have been submitted at all */
<div role="status" className="card p-8 text-center">
<p className="text-sm font-medium text-vx-text mb-2">
{t("myIntents.empty.title")}
</p>
<p className="text-xs text-vx-muted max-w-xs mx-auto mb-4">
{t("myIntents.empty.message")}
</p>
<Link
href="/"
className="inline-block px-4 py-2 rounded-lg border border-vx-sage/40 text-vx-text text-sm hover:border-vx-sage/70 transition-colors focus:outline-none focus:ring-2 focus:ring-vx-sage focus:ring-offset-2 focus:ring-offset-vx-ink rounded"
className="inline-block px-4 py-2 rounded-lg border border-vx-sage/40 text-vx-text text-sm hover:border-vx-sage/70 transition-colors focus:outline-none focus:ring-2 focus:ring-vx-sage focus:ring-offset-2 focus:ring-offset-vx-ink"
>
Make your first swap
{t("myIntents.empty.cta")}
</Link>
</div>
) : filtered.length === 0 ? (
<div role="status" className="card p-8 text-center text-sm text-vx-muted">
No intents match your filters.
/* Intents exist but the active filter combination matches nothing */
<div role="status" className="card p-8 text-center">
<p className="text-sm font-medium text-vx-text mb-2">
{t("myIntents.filterEmpty.title")}
</p>
<p className="text-xs text-vx-muted max-w-xs mx-auto mb-4">
{t("myIntents.filterEmpty.message")}
</p>
<button
type="button"
onClick={clearFilters}
className="inline-block px-4 py-2 rounded-lg border border-vx-sage/40 text-vx-text text-sm hover:border-vx-sage/70 transition-colors focus:outline-none focus:ring-2 focus:ring-vx-sage focus:ring-offset-2 focus:ring-offset-vx-ink"
>
{t("myIntents.filterEmpty.clearFilters")}
</button>
</div>
) : (
<div data-address={address} data-testid="intents-list" className="space-y-2" role="list">
Expand Down Expand Up @@ -317,6 +344,33 @@ export default function MyIntentsPage() {
})}
</div>
)}

{/* Pagination */}
{pageCount > 1 && filtered.length > 0 && (
<div className="flex items-center justify-center gap-4 mt-6">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1.5 text-xs rounded-lg border border-vx-border text-vx-muted
hover:text-vx-text hover:border-vx-sage/30 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
Previous
</button>
<span className="text-xs text-vx-muted num">
Page {page} of {pageCount}
</span>
<button
type="button"
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
disabled={page === pageCount}
className="px-3 py-1.5 text-xs rounded-lg border border-vx-border text-vx-muted
hover:text-vx-text hover:border-vx-sage/30 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
Next
</button>
</div>
)}
</>
)}
</main>
Expand Down
28 changes: 22 additions & 6 deletions src/app/solve/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -64,7 +66,7 @@ export default function SolverDetailPage({ params }: { params: { address: string
{sanitizeDisplayText(solver.name)}
</h1>
</div>
<div
<div
className={`flex-shrink-0 px-2 sm:px-3 py-1 rounded-lg text-xs font-semibold border whitespace-nowrap ${
solver.status === "active"
? "bg-vx-sage-bg text-vx-sage border-vx-sage/30"
Expand Down Expand Up @@ -117,7 +119,16 @@ export default function SolverDetailPage({ params }: { params: { address: string
</div>
</div>

{/* Fill history section */}
{/* ── Solver Timeline ─────────────────────────────────────────── */}
<div className="mb-6">
<SolverTimeline
solverAddress={solver.address}
fills={fillHistory}
isLoading={historyLoading && fillHistory.length === 0}
/>
</div>

{/* ── Fill history table ──────────────────────────────────────── */}
<div className="card overflow-hidden">
<div className="px-4 sm:px-5 py-3 sm:py-3.5 border-b border-vx-border bg-vx-surface/30">
<h2 className="text-sm font-semibold text-vx-text">Recent Fills by Solver</h2>
Expand All @@ -134,17 +145,22 @@ export default function SolverDetailPage({ params }: { params: { address: string
Couldn&apos;t load fill history right now.
</div>
) : fillHistory.filter(item => item.solver === solver.address).length === 0 ? (
<div className="p-6 sm:p-8 text-center text-sm text-vx-muted">
No fills from this solver in the history.
<div className="p-6 sm:p-8 text-center">
<p className="text-sm font-medium text-vx-text mb-1">
{t("solverDetail.fillHistory.empty.title")}
</p>
<p className="text-xs text-vx-muted max-w-xs mx-auto">
{t("solverDetail.fillHistory.empty.message")}
</p>
</div>
) : (
<div className="divide-y divide-vx-line">
{fillHistory
.filter(item => item.solver === solver.address)
.slice(0, 10)
.map(fill => (
<div
key={fill.id}
<div
key={fill.id}
className="px-4 sm:px-5 py-4 hover:bg-vx-surface/30 transition-colors"
>
<div className="flex flex-col gap-2">
Expand Down
9 changes: 6 additions & 3 deletions src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import type { FeedItem } from "@/lib/types";
import { SkeletonCard } from "./Skeleton";

const CHAIN_COLOR: Record<string, string> = {
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. */
Expand Down Expand Up @@ -139,7 +143,6 @@ export function ActivityFeedView({ items, isLoading, error, isLive }: ActivityFe
<span
className="text-[10px] text-vx-muted"
title={new Date(item.createdAt).toLocaleString()}
tabIndex={0}
>
{timeAgo(item.createdAt)}
</span>
Expand Down
Loading