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
109 changes: 109 additions & 0 deletions docs/pr/samuelisi-300-301-302-303.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
## Summary

Four independent UX / correctness improvements, one commit each:

- **#302** – reconcile wallet connection state across browser tabs
- **#303** – first-visit onboarding hint sequence
- **#300** – column-visibility preferences on `/my-intents`
- **#301** – classify swap failures and add contextual help

Closes #300
Closes #301
Closes #302
Closes #303

## Changes

### #302 – Multi-tab wallet reconciliation
- `store/wallet.ts`: new `syncFromStorage(persisted)` action. A cross-tab
disconnect (`persisted.isConnected === false`) is trusted directly; a changed
account is adopted optimistically and then re-verified with `hydrate()`; an
already-in-sync snapshot is a no-op (this is what prevents a reconciliation
loop). Exports `PERSIST_KEY` and a `PersistedWalletState` type.
- `WalletHydrator.tsx`: registers a `window` `storage` listener (once, next to
the mount-time `hydrate()`), parses the zustand-persist envelope, and forwards
the slice to `syncFromStorage`. Cleaned up on unmount.
- `docs/wallet-hydration.md`: new "Multi-tab reconciliation" section.
- Tests: `wallet.test.ts` (+3), `WalletHydrator.test.tsx` (rewritten, +3).

### #303 – Onboarding hints
- New `src/components/OnboardingHints.tsx`: dependency-free 3-step coach-mark
overlay with a spotlight ring around each target. Shown once per browser
(`localStorage` `vortex-onboarding-seen`), only when every target element is
present. Skippable at any step (Skip button, backdrop click, `Escape`),
keyboard-operable, and skips the entrance animation when `data-motion="reduce"`.
- `app/page.tsx`: mounts it and adds stable ids (`swap-card-region`,
`live-feed-region`) plus a `solver-portal-link` in the hero (new
`home.hero.solverCta` key in `en`/`es`).
- Tests: new `OnboardingHints.test.tsx` (6), `page.test.tsx` (+1, existing shell
tests suppress the sequence via `localStorage`).

### #300 – Column visibility on `/my-intents`
- New `src/hooks/useColumnVisibility.ts`: a persisted per-column show/hide map.
`alwaysVisible` columns are forced on and non-toggleable; unknown / non-boolean
keys in a stale persisted value are ignored.
- `my-intents/page.tsx`: a "Columns" dropdown (checkboxes) in the filter bar.
`pair`, `status`, and `submitted` are always visible; `chain` and `solver`
toggle. Rows now show a "submitted … ago" line.
- Tests: new `useColumnVisibility.test.ts` (5), `my-intents/page.test.tsx` (+4).

### #301 – Swap-failure classification + help
- `useSwapSubmission.ts`: new `classifySwapError(err): SwapErrorKind`
(`network` / `no-solver` / `balance` / `user-rejected` / `generic`), mirroring
`useSolverRegistration`'s `RegistrationErrorMessage` and keying off
`ApiError.status` / `TimeoutError`. The hook now also returns `errorKind`; the
raw `error` message is **kept unchanged** so no backend detail is lost.
- `SwapCard.tsx`: for a classified failure, shows one line of actionable
guidance (`SWAP_ERROR_GUIDANCE`); for `generic`, an expandable
"Why did this happen?" `<details>` with troubleshooting steps.
- Tests: `useSwapSubmission.test.ts` (+6), new `SwapCard.errors.test.tsx` (4).

## Testing

- [ ] `npm run build` / `npx tsc --noEmit` – **blocked by pre-existing breakage** (below)
- [x] New / touched suites green in isolation:
- `wallet.test.ts` 14/14, `WalletHydrator.test.tsx` 6/6
- `OnboardingHints.test.tsx` 6/6, `page.test.tsx` 7/7 (was 0/6)
- `useColumnVisibility.test.ts` 5/5, `my-intents/page.test.tsx` 21/22
- `useSwapSubmission.test.ts` 11/11, `SwapCard.test.tsx` 14/14 (was 0 – file didn't collect),
`SwapCard.errors.test.tsx` 4/4
- [x] Full suite: **~72 failed → 52 failed** (+51 new tests, all green; ~20
pre-existing failures fixed as a side effect of repairing files these features touch).
- [ ] Manual two-tab check for #302 – not done; the app does not currently run
(see below). Covered by a simulated `storage` event in `wallet.test.ts`.

### Pre-existing breakage (not introduced here)

`main` does not build, typecheck, lint, or pass its own tests at `c87dc14`,
from botched `Merge branch main into feature/…` conflict resolutions in
PRs #217–219. A full `tsc` is blocked by three files with literal
merge-conflict debris (`explore/page.tsx`, `solve/page.tsx`,
`solve/[address]/page.test.tsx`).

Files this PR had to repair **just enough to compile / render** (their existing
suites are exercised above):

- `store/wallet.ts` – dead `errorKey` reference, `wasSessionCleared` missing from
the state type; restored `errorKey: WalletErrorKey | null` and the
stale-session preservation of `lastKnownAddress` its own tests require.
- `SwapCard.tsx` – ~42 type errors (duplicate `chainPickerRef` / `chainToggleRef`
/ `closeChainPicker` / `useEffect`; undefined `dstAddress`, `slippagePct`,
`quoteFetchedAt`, `STALE_QUOTE_THRESHOLD_MS`, `quoteErrorType`). The dropped
slippage-tolerance field + min-out line were restored (they have `en`/`es`
keys and are required by the existing `SwapCard.test.tsx`); the amount input
is now `type="text" inputMode="decimal"` so 18-dp values aren't reformatted.
- `my-intents/page.tsx` – `downloadCsv` / `buildIntentsCsv` not imported,
duplicate status badge, over-riding `aria-label`s.

Still red and **left untouched** (out of scope): the 3 merge-debris files;
`Nav.tsx` / `ConnectWalletButton.tsx` / `ActivityFeed.tsx` (mocked in the
touched test suites); the i18n catalog key-parity gap; `*.stories.tsx`.
`my-intents/page.test.tsx`'s "retry button" case references undefined
`user` / `mutateMock` in its own body and can't pass without a test rewrite.

## Checklist

- [x] Self-reviewed the diff
- [x] Added or updated tests for new behaviour
- [x] No secrets or credentials committed
- [x] PR title follows conventional commits
25 changes: 25 additions & 0 deletions docs/wallet-hydration.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,28 @@ the only call that pops the Freighter approval UI. That call only happens in
strictly a read of already-granted access — if the extension would need to
prompt the user, hydration clears the session instead of prompting silently on
page load.

## Multi-tab reconciliation (#302)

`useWalletStore` persists to `localStorage` under `PERSIST_KEY` (`vortex-wallet`).
The browser's `storage` event fires in **every other same-origin tab** whenever
one tab writes that key, so [`WalletHydrator`](../src/components/WalletHydrator.tsx)
also registers a `storage` listener (once, alongside the mount-time `hydrate()`).

When another tab changes the persisted wallet slice, the listener parses the new
value and calls `useWalletStore.getState().syncFromStorage(persisted)`:

- **Already in sync** (`isConnected` and `address` match this tab) — no-op. This
is what prevents a reconciliation loop: a tab's own reconciling write lands in
the other tabs as a `storage` event, but by then every tab already agrees, so
nothing further is written.
- **Another tab disconnected** (`persisted.isConnected === false`) — trusted
directly; this tab clears its wallet state. A user-initiated disconnect is
authoritative and there's nothing to re-verify.
- **Another tab connected or switched account** — the new address is adopted
optimistically and then `hydrate()` re-confirms it against the extension
(`isConnected` / `isAllowed` / `getPublicKey`), so a tab never trusts an
account it can't verify.

The `storage` event never fires in the tab that made the change, so the
originating tab keeps the correct state from its own `set()` and is unaffected.
85 changes: 83 additions & 2 deletions src/app/my-intents/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { FeedItem } from "@/lib/types";

Expand All @@ -11,6 +11,13 @@ const { useWalletStoreMock, useMyLiveIntentsMock } = vi.hoisted(() => ({
vi.mock("@/store/wallet", () => ({ useWalletStore: useWalletStoreMock }));
vi.mock("@/store/toast", () => ({ useToastStore: vi.fn(() => ({ addToast: vi.fn() })) }));
vi.mock("@/hooks/useMyLiveIntents", () => ({ useMyLiveIntents: useMyLiveIntentsMock }));
// Nav/Footer/ConnectWalletButton carry their own (currently broken on main)
// suites and wallet/i18n context this one does not set up.
vi.mock("@/components/Nav", () => ({ Nav: () => null }));
vi.mock("@/components/Footer", () => ({ Footer: () => null }));
vi.mock("@/components/ConnectWalletButton", () => ({
ConnectWalletButton: () => <button type="button">Connect Freighter</button>,
}));

import MyIntentsPage from "./page";

Expand Down Expand Up @@ -73,6 +80,14 @@ const manyIntents: FeedItem[] = Array.from({ length: 25 }, (_, i) => ({
}));

describe("MyIntentsPage", () => {
beforeEach(() => {
localStorage.clear();
});

afterEach(() => {
localStorage.clear();
});

it("renders the main landmark with the correct id", () => {
mockWallet();
useMyLiveIntentsMock.mockReturnValue({ intents: [], isLoading: false, error: undefined, isLive: false });
Expand Down Expand Up @@ -186,6 +201,72 @@ describe("MyIntentsPage", () => {
expect(screen.getByRole("link", { name: /make your first swap/i })).toHaveAttribute("href", "/");
});

describe("column visibility (#300)", () => {
function connectedWithIntents() {
mockWallet({ address: "GABC123", isConnected: true });
useMyLiveIntentsMock.mockReturnValue({ intents, isLoading: false, error: undefined });
}

it("hides the solver column from every row when toggled off", async () => {
connectedWithIntents();
const user = userEvent.setup();
render(<MyIntentsPage />);

expect(screen.getByText(/via Alpha/)).toBeInTheDocument();

await user.click(screen.getByRole("button", { name: "Columns" }));
await user.click(screen.getByRole("checkbox", { name: "Solver" }));

expect(screen.queryByText(/via Alpha/)).not.toBeInTheDocument();
// The source chain is a separate column and stays.
expect(screen.getByText(/ethereum/)).toBeInTheDocument();
});

it("persists the preference across a remount", async () => {
connectedWithIntents();
const user = userEvent.setup();
const { unmount } = render(<MyIntentsPage />);

await user.click(screen.getByRole("button", { name: "Columns" }));
await user.click(screen.getByRole("checkbox", { name: "Source chain" }));
expect(screen.queryByText(/ethereum/)).not.toBeInTheDocument();

unmount();
connectedWithIntents();
render(<MyIntentsPage />);

expect(screen.queryByText(/ethereum/)).not.toBeInTheDocument();
});

it("keeps status and date columns non-toggleable", async () => {
connectedWithIntents();
const user = userEvent.setup();
render(<MyIntentsPage />);

await user.click(screen.getByRole("button", { name: "Columns" }));

expect(screen.getByRole("checkbox", { name: /Status/ })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: /Submitted/ })).toBeDisabled();
// Still rendered in the rows.
const list = screen.getByTestId("intents-list");
expect(within(list).getAllByText(/submitted .* ago/).length).toBeGreaterThan(0);
});

it("ignores unknown keys in a stale persisted preference", () => {
localStorage.setItem(
"vortex-my-intents-columns",
JSON.stringify({ solver: false, removedColumn: false }),
);
connectedWithIntents();
render(<MyIntentsPage />);

// Known key still applied...
expect(screen.queryByText(/via Alpha/)).not.toBeInTheDocument();
// ...and the page didn't crash on the unknown one.
expect(screen.getByTestId("intents-list")).toBeInTheDocument();
});
});

it("links each intent row to the intent detail page", () => {
mockWallet({ address: "GABC123", isConnected: true });
useMyLiveIntentsMock.mockReturnValue({ intents, isLoading: false, error: undefined });
Expand Down
87 changes: 76 additions & 11 deletions src/app/my-intents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,42 @@ import { IntentStatusBadge } from "@/components/IntentStatusBadge";
import { ConnectWalletButton } from "@/components/ConnectWalletButton";
import { useWalletStore } from "@/store/wallet";
import { useMyLiveIntents } from "@/hooks/useMyLiveIntents";
import { useColumnVisibility } from "@/hooks/useColumnVisibility";
import { CHAINS } from "@/lib/marketData";
import { SkeletonCard } from "@/components/Skeleton";
import { buildIntentsCsv, downloadCsv } from "@/lib/csv";
import { timeAgo } from "@/lib/time";
import type { IntentStatus } from "@/lib/types";

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

// On-screen row columns. `pair` and `status` and `submitted` are essential for
// scanning the list, so they can't be hidden; `chain` and `solver` are optional.
const MY_INTENTS_COLUMNS = ["pair", "chain", "solver", "status", "submitted"] as const;
type MyIntentsColumn = (typeof MY_INTENTS_COLUMNS)[number];
const ALWAYS_VISIBLE_COLUMNS: MyIntentsColumn[] = ["pair", "status", "submitted"];
const COLUMN_LABELS: Record<MyIntentsColumn, string> = {
pair: "Swap",
chain: "Source chain",
solver: "Solver",
status: "Status",
submitted: "Submitted",
};

export default function MyIntentsPage() {
const address = useWalletStore((s) => s.address);
const isConnected = useWalletStore((s) => s.isConnected);

const { intents, isLoading, error, isLive } = useMyLiveIntents(address);
const { visibility, toggle, isToggleable } = useColumnVisibility<MyIntentsColumn>(
"vortex-my-intents-columns",
MY_INTENTS_COLUMNS,
ALWAYS_VISIBLE_COLUMNS,
);

const [statusFilter, setStatusFilter] = useState<IntentStatus | "all">("all");
const [chainFilter, setChainFilter] = useState<string>("all");
const [showColumnMenu, setShowColumnMenu] = useState(false);
const [page, setPage] = useState(1);

const filtered = useMemo(() => {
Expand Down Expand Up @@ -87,7 +108,6 @@ export default function MyIntentsPage() {
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as IntentStatus | "all")}
className="bg-vx-surface border border-vx-border rounded-lg px-3 py-2 text-sm text-vx-text"
aria-label="Filter intents by status"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
Expand All @@ -104,7 +124,6 @@ export default function MyIntentsPage() {
value={chainFilter}
onChange={(e) => setChainFilter(e.target.value)}
className="bg-vx-surface border border-vx-border rounded-lg px-3 py-2 text-sm text-vx-text"
aria-label="Filter intents by chain"
>
<option value="all">All chains</option>
{CHAINS.map((c) => (
Expand All @@ -113,11 +132,48 @@ export default function MyIntentsPage() {
</select>
</label>

<div className="relative ml-auto">
<button
type="button"
onClick={() => setShowColumnMenu((open) => !open)}
aria-haspopup="true"
aria-expanded={showColumnMenu}
className="px-3 py-2 rounded-lg border border-vx-border text-xs font-semibold text-vx-muted hover:text-vx-text hover:border-vx-sage/40 transition-colors"
>
Columns
</button>
{showColumnMenu && (
<div
role="group"
aria-label="Visible columns"
className="absolute right-0 z-20 mt-1 w-44 rounded-lg border border-vx-border bg-vx-card p-2 shadow-xl"
>
{MY_INTENTS_COLUMNS.filter((c) => c !== "pair").map((column) => (
<label
key={column}
className="flex items-center gap-2 px-1.5 py-1 text-xs text-vx-text"
>
<input
type="checkbox"
checked={visibility[column]}
disabled={!isToggleable(column)}
onChange={() => toggle(column)}
/>
{COLUMN_LABELS[column]}
{!isToggleable(column) && (
<span className="ml-auto text-[10px] text-vx-muted">always</span>
)}
</label>
))}
</div>
)}
</div>

<button
type="button"
onClick={handleExportCsv}
disabled={filtered.length === 0}
className="ml-auto px-3 py-2 rounded-lg border border-vx-border text-xs font-semibold text-vx-muted hover:text-vx-text hover:border-vx-sage/40 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-vx-border disabled:hover:text-vx-muted"
className="px-3 py-2 rounded-lg border border-vx-border text-xs font-semibold text-vx-muted hover:text-vx-text hover:border-vx-sage/40 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-vx-border disabled:hover:text-vx-muted"
>
Export CSV
</button>
Expand All @@ -129,10 +185,13 @@ export default function MyIntentsPage() {

{/* List */}
{isLoading ? (
<div className="space-y-2" role="status" aria-label="Loading intents">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-14 bg-vx-surface/40 rounded-lg border border-vx-line animate-pulse" />
))}
<div role="status" className="space-y-2 text-sm text-vx-muted">
Loading your intents...
<div aria-hidden="true" className="space-y-2 pt-2">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-14 bg-vx-surface/40 rounded-lg border border-vx-line animate-pulse" />
))}
</div>
</div>
) : error ? (
<div role="alert" className="card p-8 text-center text-sm text-vx-muted">
Expand Down Expand Up @@ -164,10 +223,16 @@ export default function MyIntentsPage() {
<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}
{(visibility.chain || visibility.solver) && (
<div className="text-xs text-vx-muted capitalize">
{visibility.chain && item.srcChain}
{visibility.chain && visibility.solver && " · "}
{visibility.solver && `via ${item.solver}`}
</div>
)}
<div className="text-[11px] text-vx-muted mt-0.5">
submitted {timeAgo(item.createdAt)}
</div>
<IntentStatusBadge status={item.status} />
</div>
<div className="self-start sm:self-center">
<IntentStatusBadge status={item.status} />
Expand Down
Loading