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
95 changes: 89 additions & 6 deletions src/screens/AccountScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach,describe, expect, it, vi } from "vitest";

import { useSorokit } from "@/context/useSorokit";
Expand All @@ -21,14 +21,21 @@ vi.mock("@/components/ClaimableBalanceCard", () => ({
ClaimableBalanceCard: () => <div>Claimable Balances</div>,
}));

type Ctx = ReturnType<typeof useSorokit>;

function mockContext(overrides: Partial<Ctx> = {}) {
vi.mocked(useSorokit).mockReturnValue({
isConnected: false,
isLoadingAccount: false,
refreshAccount: vi.fn(),
...overrides,
} as unknown as Ctx);
}

describe("AccountScreen", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useSorokit).mockReturnValue({
isConnected: false,
isLoadingAccount: false,
refreshAccount: vi.fn(),
} as unknown as ReturnType<typeof useSorokit>);
mockContext();
});

it("renders the screen heading as a level 2 heading", () => {
Expand All @@ -38,4 +45,80 @@ describe("AccountScreen", () => {
).toBeInTheDocument();
expect(screen.getByText("Balances and account details")).toBeInTheDocument();
});

describe("refresh control (#81)", () => {
it("does not render the refresh button when disconnected", () => {
mockContext({ isConnected: false });
render(<AccountScreen />);
expect(
screen.queryByRole("button", { name: /refresh account data/i }),
).not.toBeInTheDocument();
});

it("renders the refresh button when connected", () => {
mockContext({ isConnected: true });
render(<AccountScreen />);
expect(
screen.getByRole("button", { name: /refresh account data/i }),
).toBeInTheDocument();
});

it("disables the refresh button while isLoadingAccount is true", () => {
mockContext({ isConnected: true, isLoadingAccount: true });
render(<AccountScreen />);
const button = screen.getByRole("button", {
name: /refresh account data/i,
});
expect(button).toBeDisabled();
expect(button).toHaveAttribute("aria-busy", "true");
});

it("keeps the refresh button enabled when not loading", () => {
mockContext({ isConnected: true, isLoadingAccount: false });
render(<AccountScreen />);
expect(
screen.getByRole("button", { name: /refresh account data/i }),
).toBeEnabled();
});

it("calls refreshAccount when the refresh button is clicked", async () => {
const refreshAccount = vi.fn().mockResolvedValue(undefined);
mockContext({ isConnected: true, refreshAccount });
render(<AccountScreen />);

fireEvent.click(
screen.getByRole("button", { name: /refresh account data/i }),
);

expect(refreshAccount).toHaveBeenCalledTimes(1);
// Let the post-refresh state update settle to avoid act() warnings.
await waitFor(() =>
expect(screen.getByText(/last updated/i)).toBeInTheDocument(),
);
});
});

describe("last updated timestamp (#81)", () => {
it("does not show a last-updated timestamp before any refresh", () => {
mockContext({ isConnected: true });
render(<AccountScreen />);
expect(screen.queryByText(/last updated/i)).not.toBeInTheDocument();
});

it("shows the last-updated timestamp after refreshAccount resolves", async () => {
const refreshAccount = vi.fn().mockResolvedValue(undefined);
mockContext({ isConnected: true, refreshAccount });
render(<AccountScreen />);

expect(screen.queryByText(/last updated/i)).not.toBeInTheDocument();

fireEvent.click(
screen.getByRole("button", { name: /refresh account data/i }),
);

await waitFor(() =>
expect(screen.getByText(/last updated/i)).toBeInTheDocument(),
);
});
});
});
16 changes: 14 additions & 2 deletions src/screens/AccountScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Refresh01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useState } from "react";

import { AccountCard } from "@/components/AccountCard";
import { BalanceList } from "@/components/BalanceList";
Expand All @@ -9,18 +10,29 @@ import { useSorokit } from "@/context/useSorokit";

export function AccountScreen() {
const { isConnected, isLoadingAccount, refreshAccount } = useSorokit();
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);

async function handleRefresh() {
await refreshAccount();
setLastUpdated(new Date());
}

return (
<div className="flex flex-col gap-5">
<h2 className="text-[18px] font-semibold text-ink">Account</h2>
<p className="text-[13px] text-ink-3 -mt-3">Balances and account details</p>
{isConnected && (
<div className="flex justify-end">
<div className="flex items-center justify-end gap-3">
{lastUpdated && (
<span className="text-[12px] text-ink-3">
Last updated {lastUpdated.toLocaleTimeString()}
</span>
)}
<Button
size="sm"
variant="ghost"
loading={isLoadingAccount}
onClick={refreshAccount}
onClick={handleRefresh}
aria-label="Refresh account data"
>
<HugeiconsIcon icon={Refresh01Icon} size={14} strokeWidth={1.5} />
Expand Down
47 changes: 47 additions & 0 deletions src/screens/ConnectScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,51 @@ describe("ConnectScreen", () => {

expect(connectWallet).toHaveBeenCalledTimes(1);
});

describe("supported wallet options (#82)", () => {
it("renders a logo and label for each supported wallet", () => {
render(<ConnectScreen />);

for (const name of ["Freighter", "xBull", "Albedo", "Lobstr"]) {
expect(screen.getByText(name)).toBeInTheDocument();
expect(
screen.getByRole("img", { name: `${name} logo` }),
).toBeInTheDocument();
}
});
});

describe("'New to Stellar?' collapsible (#82)", () => {
it("renders the collapsible details element in the DOM", () => {
const { container } = render(<ConnectScreen />);
const details = container.querySelector("details");
expect(details).toBeInTheDocument();
expect(screen.getByText("New to Stellar?")).toBeInTheDocument();
});

it("is collapsed by default and expands when the summary is clicked", () => {
const { container } = render(<ConnectScreen />);
const details = container.querySelector("details") as HTMLDetailsElement;

expect(details.open).toBe(false);

fireEvent.click(screen.getByText("New to Stellar?"));

expect(details.open).toBe(true);
expect(
screen.getByRole("link", { name: /how stellar accounts work/i }),
).toBeInTheDocument();
});
});

describe("responsive hero (#82)", () => {
it("applies the responsive hide class to the hero image", () => {
render(<ConnectScreen />);
const hero = screen.getByRole("img", {
name: /sorokit wallet dashboard preview/i,
});
expect(hero.className).toContain("hidden");
expect(hero.className).toContain("sm:block");
});
});
});
60 changes: 57 additions & 3 deletions src/screens/ConnectScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { Cancel01Icon, Wallet01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";

import heroImg from "@/assets/hero.png";
import { Button } from "@/components/ui/Button";
import { useSorokit } from "@/context/useSorokit";

/** Wallets supported by the underlying sorokit-core connector. */
const SUPPORTED_WALLETS = ["Freighter", "xBull", "Albedo", "Lobstr"] as const;

export function ConnectScreen() {
const { connectWallet, isConnecting, error, clearError } = useSorokit();

Expand Down Expand Up @@ -40,11 +43,11 @@ export function ConnectScreen() {
</div>
</div>

{/* Hero image */}
{/* Hero image — hidden on short/mobile viewports to keep the card in view */}
<img
src={heroImg}
alt="sorokit wallet dashboard preview"
className="w-full rounded-xl object-cover"
className="hidden sm:block w-full rounded-xl object-cover"
/>

{/* Card */}
Expand Down Expand Up @@ -88,10 +91,61 @@ export function ConnectScreen() {
Connecting to your wallet…
</p>
)}
{/* Supported wallet options */}
<div className="flex flex-col items-center gap-2">
<p className="text-[11px] text-ink-4 text-center uppercase tracking-[0.1em]">
Supported wallets
</p>
<ul className="flex flex-wrap items-center justify-center gap-2">
{SUPPORTED_WALLETS.map((name) => (
<li
key={name}
className="flex items-center gap-1.5 rounded-lg border border-line bg-surface-2 px-2.5 py-1.5"
>
<span
role="img"
aria-label={`${name} logo`}
className="text-ink-3"
>
<HugeiconsIcon
icon={Wallet01Icon}
size={14}
color="currentColor"
strokeWidth={1.5}
/>
</span>
<span className="text-[12px] text-ink-2">{name}</span>
</li>
))}
</ul>
</div>

<p className="text-[11px] text-ink-4 text-center">
Powered by sorokit-core · Stellar network
</p>
</div>

{/* New to Stellar? — collapsible onboarding help */}
<details className="border-t border-line px-5 py-4">
<summary className="cursor-pointer text-[13px] font-medium text-ink list-none">
New to Stellar?
</summary>
<div className="mt-3 flex flex-col gap-2 text-[12px] text-ink-3 leading-relaxed">
<p>
A Stellar wallet lets you hold assets and sign transactions.
Install one of the supported wallets above, create an account,
then come back and connect.
</p>
<a
href="https://developers.stellar.org/docs/learn/fundamentals/stellar-data-structures/accounts"
target="_blank"
rel="noopener noreferrer"
className="text-brand hover:underline"
>
Learn how Stellar accounts work →
</a>
</div>
</details>
</div>
</div>
</div>
Expand Down