Skip to content
Open
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
75 changes: 53 additions & 22 deletions src/app/explore/ExplorePageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,28 +132,59 @@ export default function ExplorePageClient() {
</div>
) : (
<>
<div className="space-y-2">
{paginated.map((item) => (
<Link
key={item.id}
href={`/explore/${item.id}`}
className="flex items-center gap-4 p-4 bg-vx-surface/40 rounded-lg border border-vx-line
hover:border-vx-border transition-colors"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-vx-text truncate">
{item.srcAmount} {item.srcToken} → {item.dstToken}
</div>
<div className="text-xs text-vx-muted capitalize">
{item.srcChain} · via {item.solver}
</div>
</div>
<IntentStatusBadge status={item.status} />
<span className="text-xs text-vx-muted num flex-shrink-0 w-16 text-right">
{timeAgo(item.createdAt)}
</span>
</Link>
))}
{/*
Native <table> so screen readers announce column headers, row/column
counts, and allow table-navigation mode.
Visual layout is preserved via display:block tricks on narrow viewports
if needed, but the semantic structure stays table-based.
*/}
<div className="overflow-x-auto rounded-lg border border-vx-line">
<table className="w-full border-collapse">
<thead>
<tr className="bg-vx-surface/50 border-b border-vx-line">
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold text-vx-muted uppercase tracking-wide">
Swap
</th>
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold text-vx-muted uppercase tracking-wide">
Chain · Solver
</th>
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold text-vx-muted uppercase tracking-wide">
Status
</th>
<th scope="col" className="px-4 py-3 text-right text-xs font-semibold text-vx-muted uppercase tracking-wide">
Age
</th>
</tr>
</thead>
<tbody className="divide-y divide-vx-line">
{paginated.map((item) => (
<tr
key={item.id}
className="bg-vx-surface/40 hover:bg-vx-surface/60 transition-colors"
>
<td className="px-4 py-4">
<Link
href={`/explore/${item.id}`}
className="block text-sm font-medium text-vx-text truncate max-w-[200px]
focus:outline-none focus:ring-2 focus:ring-vx-sage focus:ring-offset-2
focus:ring-offset-vx-ink rounded"
>
{item.srcAmount} {item.srcToken} → {item.dstToken}
</Link>
</td>
<td className="px-4 py-4 text-xs text-vx-muted capitalize whitespace-nowrap">
{item.srcChain} · via {item.solver}
</td>
<td className="px-4 py-4">
<IntentStatusBadge status={item.status} />
</td>
<td className="px-4 py-4 text-xs text-vx-muted num text-right whitespace-nowrap">
{timeAgo(item.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>

{pageCount > 1 && (
Expand Down
9 changes: 2 additions & 7 deletions src/app/explore/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,6 @@ export default function ExplorePage() {
)}
</main>
<Footer />
</div>Merge
),
ssr: false,
});

export default function ExplorePage() {
return <ExplorePageClient />;
</div>
);
}
137 changes: 115 additions & 22 deletions src/app/my-intents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,27 @@ import { useWalletStore } from "@/store/wallet";
import { useMyLiveIntents } from "@/hooks/useMyLiveIntents";
import { CHAINS } from "@/lib/marketData";
import { SkeletonCard } from "@/components/Skeleton";
import type { IntentStatus } from "@/lib/types";
import { buildIntentsCsv, downloadCsv } from "@/lib/csv";
import type { FeedItem, IntentStatus } from "@/lib/types";

const STATUS_OPTIONS: Array<IntentStatus | "all"> = ["all", "pending", "accepted", "filled", "failed"];
const PAGE_SIZE = 10;

/**
* Build a homepage URL that pre-fills SwapCard with an intent's parameters.
* Destination address is intentionally NOT carried over (stale/unintended
* destination risk — the user must enter it fresh).
*/
function swapAgainHref(item: FeedItem): string {
const params = new URLSearchParams({
srcChain: item.srcChain,
srcToken: item.srcToken,
amount: item.srcAmount,
dstToken: item.dstToken,
});
return `/?${params.toString()}`;
}

export default function MyIntentsPage() {
const address = useWalletStore((s) => s.address);
const isConnected = useWalletStore((s) => s.isConnected);
Expand Down Expand Up @@ -153,27 +169,104 @@ export default function MyIntentsPage() {
No intents match your filters.
</div>
) : (
<div data-address={address} data-testid="intents-list" className="space-y-2" role="list">
{filtered.map((item) => (
<Link
key={item.id}
href={`/explore/${item.id}`}
className="flex flex-col sm:flex-row sm:items-center gap-4 p-4 bg-vx-surface/40 rounded-lg border border-vx-line hover:border-vx-sage/40 active:bg-vx-surface/60 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-vx-text truncate">
{item.srcAmount} {item.srcToken} → {item.dstToken}
</div>
<div className="text-xs text-vx-muted capitalize">
{item.srcChain} · via {item.solver}
</div>
<IntentStatusBadge status={item.status} />
</div>
<div className="self-start sm:self-center">
<IntentStatusBadge status={item.status} />
</div>
</Link>
))}
/*
Native <table> so screen readers announce column headers and allow
table-navigation mode. Visual design is unchanged — cells use the
same spacing and colours as the previous card-stack layout.
*/
<div className="overflow-x-auto rounded-lg border border-vx-line">
<table
data-address={address}
data-testid="intents-list"
className="w-full border-collapse"
>
<thead>
<tr className="bg-vx-surface/50 border-b border-vx-line">
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold text-vx-muted uppercase tracking-wide">
Swap
</th>
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold text-vx-muted uppercase tracking-wide">
Chain · Solver
</th>
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold text-vx-muted uppercase tracking-wide">
Status
</th>
<th scope="col" className="px-4 py-3 text-right text-xs font-semibold text-vx-muted uppercase tracking-wide">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-vx-line">
{paginated.map((item) => (
<tr
key={item.id}
className="bg-vx-surface/40 hover:bg-vx-surface/60 transition-colors"
>
<td className="px-4 py-4">
<Link
href={`/explore/${item.id}`}
className="block text-sm font-medium text-vx-text truncate max-w-[220px]
focus:outline-none focus:ring-2 focus:ring-vx-sage focus:ring-offset-2
focus:ring-offset-vx-ink rounded"
>
{item.srcAmount} {item.srcToken} → {item.dstToken}
</Link>
</td>
<td className="px-4 py-4 text-xs text-vx-muted capitalize whitespace-nowrap">
{item.srcChain} · via {item.solver}
</td>
<td className="px-4 py-4">
<IntentStatusBadge status={item.status} />
</td>
<td className="px-4 py-4 text-right">
{/* Swap again — carries source chain/token/amount + dst token;
destination address is intentionally left blank so the user
must enter a fresh one (never reuse a stale destination). */}
<Link
href={swapAgainHref(item)}
data-testid="swap-again-link"
aria-label={`Swap again: ${item.srcAmount} ${item.srcToken} to ${item.dstToken}`}
className="inline-block px-2.5 py-1 rounded-lg border border-vx-sage/30 text-vx-sage
text-[11px] font-semibold hover:bg-vx-sage/10 active:bg-vx-sage/20
transition-colors focus:outline-none focus:ring-2 focus:ring-vx-sage
focus:ring-offset-2 focus:ring-offset-vx-ink whitespace-nowrap"
>
Swap again
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}

{/* Pagination */}
{pageCount > 1 && (
<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>
)}
</>
Expand Down
25 changes: 24 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import React, { Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Nav } from "@/components/Nav";
import { Footer } from "@/components/Footer";
import { SwapCard } from "@/components/SwapCard";
Expand Down Expand Up @@ -52,6 +54,25 @@ function IntentPipeline() {
);
}

// ─── Prefill-aware swap card ───────────────────────────────────────────────────
// Reads ?srcChain=&srcToken=&amount=&dstToken= from the URL and passes them as
// initial values. Any missing or invalid param falls back to SwapCard defaults.
// Wrapped in Suspense because useSearchParams requires it in Next.js 14.

function SwapCardWithPrefill() {
const params = useSearchParams();
const props: React.ComponentProps<typeof SwapCard> = {};
const srcChain = params.get("srcChain");
const srcToken = params.get("srcToken");
const amount = params.get("amount");
const dstToken = params.get("dstToken");
if (srcChain) props.initialChain = srcChain;
if (srcToken) props.initialSrcToken = srcToken;
if (amount) props.initialAmount = amount;
if (dstToken) props.initialDstToken = dstToken;
return <SwapCard {...props} />;
}

// ─── Page ─────────────────────────────────────────────────────────────────────

export default function HomePage() {
Expand Down Expand Up @@ -118,7 +139,9 @@ export default function HomePage() {

{/* Right: swap card */}
<div className="lg:sticky lg:top-24">
<SwapCard />
<Suspense fallback={<SwapCard />}>
<SwapCardWithPrefill />
</Suspense>

{/* Supported chains */}
<div className="mt-5">
Expand Down
45 changes: 40 additions & 5 deletions src/app/solve/SolvePageClient.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"use client";

import { useState } from "react";
import { useEffect, useState } from "react";
import { Nav } from "@/components/Nav";
import { SkeletonCard } from "@/components/Skeleton";
import { useSolvers } from "@/hooks/useSolvers";
import { useOpenIntents } from "@/hooks/useOpenIntents";
import { useAcceptIntent } from "@/hooks/useAcceptIntent";
import { useSolverRegistration } from "@/hooks/useSolverRegistration";
import { useLocalStorageDraft } from "@/hooks/useLocalStorageDraft";
import { useWalletStore } from "@/store/wallet";
import { timeRemaining } from "@/lib/time";
import { isValidStellarPublicKey } from "@/lib/stellarAddress";
import { getMessage } from "@/i18n/messages";
Expand All @@ -21,6 +23,12 @@ const usdCompact = (value: number) =>

const MIN_BOND_USD = 50;

/** Shape of the persisted registration draft. */
type RegistrationDraft = {
address: string;
bond: string;
};

const REGISTRATION_LABEL: Record<string, string> = {
connecting: getMessage("solve.register.states.connecting"),
building: getMessage("solve.register.states.building"),
Expand All @@ -34,11 +42,37 @@ export default function SolvePageClient() {
const { intents: openIntents, isLoading: intentsLoading, error: intentsError } = useOpenIntents();
const { accept, acceptingId, error: acceptError } = useAcceptIntent();

const [address, setAddress] = useState("");
const [bond, setBond] = useState("");
// Draft persistence — scoped to the currently connected wallet so that
// switching wallets never silently restores the wrong address.
const connectedAddress = useWalletStore((s) => s.address);
const [draft, setDraft, clearDraft] = useLocalStorageDraft<RegistrationDraft>(
"vortex:solver-registration-draft",
connectedAddress ?? null,
);

const [address, setAddress] = useState(draft?.address ?? "");
const [bond, setBond] = useState(draft?.bond ?? "");

// Sync form fields into the draft whenever they change.
const handleAddressChange = (value: string) => {
setAddress(value);
setDraft({ address: value, bond });
};
const handleBondChange = (value: string) => {
setBond(value);
setDraft({ address, bond: value });
};

const registration = useSolverRegistration();
const isRegistering = registration.status in REGISTRATION_LABEL;

// Clear draft after successful submission.
useEffect(() => {
if (registration.status === "success") {
clearDraft();
}
}, [registration.status, clearDraft]);

const addressError =
address && !isValidStellarPublicKey(address)
? getMessage("solve.register.validation.invalidAddress")
Expand All @@ -55,6 +89,7 @@ export default function SolvePageClient() {
registration.reset();
setAddress("");
setBond("");
clearDraft();
return;
}
if (!canRegister) return;
Expand Down Expand Up @@ -330,7 +365,7 @@ export default function SolvePageClient() {
id="solver-address"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value.trim())}
onChange={(e) => handleAddressChange(e.target.value.trim())}
placeholder={getMessage("solve.register.addressPlaceholder")}
aria-invalid={Boolean(addressError)}
aria-describedby={addressError ? "solver-address-error" : undefined}
Expand All @@ -353,7 +388,7 @@ export default function SolvePageClient() {
id="solver-bond"
type="number"
value={bond}
onChange={(e) => setBond(e.target.value)}
onChange={(e) => handleBondChange(e.target.value)}
placeholder={getMessage("solve.register.bondPlaceholder")}
aria-invalid={Boolean(bondError)}
aria-describedby={bondError ? "solver-bond-error" : undefined}
Expand Down
2 changes: 1 addition & 1 deletion src/app/solve/[address]/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -550,4 +550,4 @@ describe("SolverDetailPage", () => {
const skeletons = screen.queryAllByTestId("skeleton");
expect(skeletons.length).toBe(0);
});
});
});});
Loading