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
1,133 changes: 361 additions & 772 deletions package-lock.json

Large diffs are not rendered by default.

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

export default function ExplorePage() {
return <ExplorePageClient />;
</div>
);
}


7 changes: 7 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,10 @@ html[data-motion="reduce"] *, html[data-motion="reduce"] *::before, html[data-mo
html[data-motion="allow"] *, html[data-motion="allow"] *::before, html[data-motion="allow"] *::after {
scroll-behavior: smooth;
}

/* Safe-area padding for notched/dynamic-island iOS devices */
@layer utilities {
.pb-safe {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
}
134 changes: 129 additions & 5 deletions src/app/solve/SolvePageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,32 @@ const usdCompact = (value: number) =>

const MIN_BOND_USD = 50;

type SortKey = "fills" | "volumeUsd" | "avgFillTimeSeconds" | "successRatePct";
type SortDir = "asc" | "desc" | "none";

function SortIcon({ direction }: { direction: SortDir }) {
return (
<svg aria-hidden="true" className="w-3 h-3 flex-shrink-0" viewBox="0 0 12 12" fill="none">
{/* Up arrow */}
<path
d="M6 2L4 5h4L6 2z"
fill={direction === "asc" ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={direction === "asc" ? 0 : 1}
opacity={direction === "asc" ? 1 : 0.3}
/>
{/* Down arrow */}
<path
d="M6 10L4 7h4L6 10z"
fill={direction === "desc" ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={direction === "desc" ? 0 : 1}
opacity={direction === "desc" ? 1 : 0.3}
/>
</svg>
);
}

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

const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>("none");

const [address, setAddress] = useState("");
const [bond, setBond] = useState("");
const registration = useSolverRegistration();
Expand All @@ -50,6 +79,31 @@ export default function SolvePageClient() {
const canRegister =
Boolean(address) && Boolean(bond) && !addressError && !bondError && !isRegistering;

const sortedSolvers = [...solvers].sort((a, b) => {
if (!sortKey || sortDir === "none") return 0;
const aVal = a[sortKey];
const bVal = b[sortKey];
// Stable numeric comparison
if (typeof aVal === "number" && typeof bVal === "number") {
return sortDir === "asc" ? aVal - bVal : bVal - aVal;
}
return 0;
});

const handleSort = (key: SortKey) => {
if (sortKey !== key) {
setSortKey(key);
setSortDir("asc");
} else if (sortDir === "asc") {
setSortDir("desc");
} else if (sortDir === "desc") {
setSortDir("none");
setSortKey(null);
} else {
setSortDir("asc");
}
};

const handleRegister = () => {
if (registration.status === "success") {
registration.reset();
Expand Down Expand Up @@ -138,10 +192,80 @@ export default function SolvePageClient() {
aria-labelledby="tab-leaderboard"
className="card overflow-hidden"
>
<div className="px-5 py-3.5 border-b border-vx-border bg-vx-surface/30">
<span className="text-sm font-semibold text-vx-text">
{getMessage("solve.leaderboard.title")}
</span>
<div className="px-3 sm:px-5 py-3 sm:py-3.5 border-b border-vx-border bg-vx-surface/30">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-vx-text">
{getMessage("solve.leaderboard.title")}
</span>
<div className="hidden sm:flex items-center gap-1" role="group" aria-label="Sort leaderboard">
{([
["fills", "Fills"],
["volumeUsd", "Volume"],
["avgFillTimeSeconds", "Avg Time"],
["successRatePct", "Success %"],
] as [SortKey, string][]).map(([key, label]) => {
const isActive = sortKey === key && sortDir !== "none";
const ariaSortValue: "ascending" | "descending" | "none" =
sortKey === key && sortDir !== "none"
? sortDir === "asc" ? "ascending" : "descending"
: "none";
return (
<button
key={key}
type="button"
onClick={() => handleSort(key)}
aria-sort={ariaSortValue}
aria-label={`Sort by ${label}${
sortKey === key && sortDir !== "none"
? sortDir === "asc" ? ", ascending" : ", descending"
: ""
}`}
className={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors
${
isActive
? "bg-vx-sage-bg text-vx-sage border border-vx-sage/30"
: "text-vx-muted hover:text-vx-text border border-transparent hover:border-vx-border"
}`}
>
{label}
<SortIcon direction={sortKey === key ? sortDir : "none"} />
</button>
);
})}
</div>
</div>
{/* Mobile sort: compact dropdown alternative */}
<div className="flex sm:hidden items-center gap-1 mt-2 flex-wrap" role="group" aria-label="Sort leaderboard">
{([
["fills", "Fills"],
["volumeUsd", "Volume"],
["avgFillTimeSeconds", "Avg Time"],
["successRatePct", "Success %"],
] as [SortKey, string][]).map(([key, label]) => {
const isActive = sortKey === key && sortDir !== "none";
const ariaSortValue: "ascending" | "descending" | "none" =
sortKey === key && sortDir !== "none"
? sortDir === "asc" ? "ascending" : "descending"
: "none";
return (
<button
key={key}
type="button"
onClick={() => handleSort(key)}
aria-sort={ariaSortValue}
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] transition-colors
${
isActive
? "bg-vx-sage-bg text-vx-sage border border-vx-sage/30"
: "text-vx-muted hover:text-vx-text border border-transparent hover:border-vx-border"
}`}
>
{label}
<SortIcon direction={sortKey === key ? sortDir : "none"} />
</button>
);
})}
</div>
</div>

{solversLoading && solvers.length === 0 ? (
Expand All @@ -158,7 +282,7 @@ export default function SolvePageClient() {
</div>
) : (
<div className="divide-y divide-vx-line">
{solvers.map((s, i) => (
{sortedSolvers.map((s, i) => (
<Link
key={s.address}
href={`/solve/${s.address}`}
Expand Down
1 change: 1 addition & 0 deletions src/app/solve/[address]/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -551,3 +551,4 @@ describe("SolverDetailPage", () => {
expect(skeletons.length).toBe(0);
});
});
});
8 changes: 7 additions & 1 deletion src/app/solve/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useSolver } from "@/hooks/useSolver";
import { useIntentFeed } from "@/hooks/useIntentFeed";
import { timeAgo } from "@/lib/time";
import { CHAINS } from "@/lib/marketData";
import { QrCode } from "@/components/QrCode";

function truncateAddress(address: string) {
if (address.length <= 12) return address;
Expand Down Expand Up @@ -78,9 +79,14 @@ export default function SolverDetailPage({ params }: { params: { address: string
</div>
</div>

<div className="flex items-center gap-2 text-xs sm:text-sm text-vx-muted font-mono break-all">
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm text-vx-muted font-mono break-all">
<span>Address: {params.address}</span>
<CopyButton value={params.address} label="Copy solver address" />
<QrCode
value={params.address}
label={`QR code for solver address ${params.address}`}
size={160}
/>
</div>

{/* Metrics grid */}
Expand Down
7 changes: 1 addition & 6 deletions src/app/solve/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -479,10 +479,5 @@ export default function SolvePage() {

<Footer />
</div>
),
ssr: false,
});

export default function SolvePage() {
return <SolvePageClient />;
);
}
7 changes: 7 additions & 0 deletions src/components/ConnectWalletButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useWalletStore } from "@/store/wallet";
import { useToastStore } from "@/store/toast";
import { useTranslation } from "@/lib/i18n/I18nProvider";
import { QrCode } from "./QrCode";

const FREIGHTER_INSTALL_URL = "https://www.freighter.app/";

Expand Down Expand Up @@ -40,6 +41,12 @@ export function ConnectWalletButton({ compact = false }: { compact?: boolean })
<span aria-hidden="true" className="hidden group-hover:inline group-focus-visible:inline">Disconnect</span>
</button>

<QrCode
value={address}
label={`QR code for wallet address ${truncateAddress(address)}`}
size={160}
/>

{networkMismatch && (
<p
role="alert"
Expand Down
60 changes: 60 additions & 0 deletions src/components/QrCode.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { Meta, StoryObj } from "@storybook/react";
import { QrCode } from "./QrCode";

const meta: Meta<typeof QrCode> = {
title: "Components/QrCode",
component: QrCode,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Dependency-free inline-SVG QR code with a show/hide toggle. " +
"Uses a from-scratch QR encoder (ISO/IEC 18004, ECC level M, versions 1–10). " +
"Screen-reader users receive an aria-label describing the encoded value.",
},
},
},
tags: ["autodocs"],
argTypes: {
size: { control: { type: "range", min: 80, max: 400, step: 20 } },
},
};

export default meta;
type Story = StoryObj<typeof QrCode>;

export const StellarAddress: Story = {
args: {
value: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
label: "Stellar address GBBD…LA5",
size: 200,
},
};

export const SolverAddress: Story = {
name: "Solver address (longer label)",
args: {
value: "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKM0JLZWH0M0Q5O8N1BXU",
label: "QR code for solver address GCEZ…BXU",
size: 200,
},
};

export const Small: Story = {
name: "Small (80 px minimum)",
args: {
value: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
label: "Stellar address GBBD…LA5",
size: 80,
},
};

export const Large: Story = {
name: "Large (320 px)",
args: {
value: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
label: "Stellar address GBBD…LA5",
size: 320,
},
};
Loading