diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bb70dd73..f2dd4d26 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@fontsource-variable/fraunces": "5.3.0", "@fontsource/instrument-sans": "5.3.0", "@fontsource/spline-sans-mono": "5.3.0", + "@phosphor-icons/react": "2.1.10", "react": "19.2.0", "react-dom": "19.2.0" }, @@ -1067,6 +1068,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.38", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8365892f..dfd8b9d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@fontsource-variable/fraunces": "5.3.0", "@fontsource/instrument-sans": "5.3.0", "@fontsource/spline-sans-mono": "5.3.0", + "@phosphor-icons/react": "2.1.10", "react": "19.2.0", "react-dom": "19.2.0" }, diff --git a/frontend/public/assets/USDtz.svg b/frontend/public/assets/USDtz.svg new file mode 100644 index 00000000..359ecc7c --- /dev/null +++ b/frontend/public/assets/USDtz.svg @@ -0,0 +1,22 @@ + + + Artboard + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/XTZ.svg b/frontend/public/assets/XTZ.svg new file mode 100644 index 00000000..3af971a9 --- /dev/null +++ b/frontend/public/assets/XTZ.svg @@ -0,0 +1,10 @@ + + + XTZ.e6d31ff6a59ad3bc98d68fe7c064a5eb + + + + + + + \ No newline at end of file diff --git a/frontend/public/assets/fXTZ.svg b/frontend/public/assets/fXTZ.svg new file mode 100644 index 00000000..d306e580 --- /dev/null +++ b/frontend/public/assets/fXTZ.svg @@ -0,0 +1,27 @@ + + + ꜰXTZ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/assets/fstXTZ.svg b/frontend/public/assets/fstXTZ.svg new file mode 100644 index 00000000..f5c67b0c --- /dev/null +++ b/frontend/public/assets/fstXTZ.svg @@ -0,0 +1,39 @@ + + + stXTZ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/assets/ftzBTC.svg b/frontend/public/assets/ftzBTC.svg new file mode 100644 index 00000000..9d37e781 --- /dev/null +++ b/frontend/public/assets/ftzBTC.svg @@ -0,0 +1,27 @@ + + + ꜰtzBTC + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/assets/fusdt.svg b/frontend/public/assets/fusdt.svg new file mode 100644 index 00000000..fda925ba --- /dev/null +++ b/frontend/public/assets/fusdt.svg @@ -0,0 +1,27 @@ + + + ꜰUSDt + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/assets/fusdtz.svg b/frontend/public/assets/fusdtz.svg new file mode 100644 index 00000000..60df3ceb --- /dev/null +++ b/frontend/public/assets/fusdtz.svg @@ -0,0 +1,29 @@ + + + ꜰUSDtz + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/assets/guilloche-source.svg b/frontend/public/assets/guilloche-source.svg new file mode 100644 index 00000000..f1e747d0 --- /dev/null +++ b/frontend/public/assets/guilloche-source.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/stacy_logo.png b/frontend/public/assets/stacy_logo.png new file mode 100644 index 00000000..114e6602 Binary files /dev/null and b/frontend/public/assets/stacy_logo.png differ diff --git a/frontend/public/assets/tezHeader.svg b/frontend/public/assets/tezHeader.svg new file mode 100644 index 00000000..e860edf9 --- /dev/null +++ b/frontend/public/assets/tezHeader.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/tezfin-logo.svg b/frontend/public/assets/tezfin-logo.svg new file mode 100644 index 00000000..ca2a242a --- /dev/null +++ b/frontend/public/assets/tezfin-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/tzBTC.svg b/frontend/public/assets/tzBTC.svg new file mode 100644 index 00000000..484f240e --- /dev/null +++ b/frontend/public/assets/tzBTC.svg @@ -0,0 +1,20 @@ + + + Artboard Copy 8 + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/assets/usdt.svg b/frontend/public/assets/usdt.svg new file mode 100644 index 00000000..908769ba --- /dev/null +++ b/frontend/public/assets/usdt.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/app/App.test.tsx b/frontend/src/app/App.test.tsx index 41893d34..be248bcf 100644 --- a/frontend/src/app/App.test.tsx +++ b/frontend/src/app/App.test.tsx @@ -1,15 +1,135 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; describe("App", () => { - it("renders the TezFin application frame", () => { + beforeEach(() => { + window.history.replaceState({}, "", "/?state=near-liquidation"); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders the complete approved dashboard state", () => { render(); - expect(screen.getByLabelText("TezFin")).toBeInTheDocument(); expect( - screen.getByRole("navigation", { name: "Primary navigation" }), + screen.getByRole("link", { name: "TezFin dashboard" }), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Skip to content" })).toHaveAttribute( + "href", + "#dashboard", + ); + expect( + screen.getByRole("alert"), + ).toHaveTextContent("Your position is close to liquidation"); + expect( + screen.getByRole("region", { name: "Supplying" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "Borrowing" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "Assets to Supply" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "Assets to Borrow" }), ).toBeInTheDocument(); - expect(screen.getByRole("main")).toHaveAttribute("id", "main-content"); + expect(screen.getByRole("main")).toHaveAttribute("id", "dashboard"); + }); + + it("switches between the approved account fixtures", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Disconnected" })); + + expect( + screen.getByRole("heading", { name: "Your TezFin passbook" }), + ).toBeInTheDocument(); + expect( + screen.getAllByRole("button", { name: "Connect wallet" }), + ).toHaveLength(2); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(window.location.search).toContain("state=disconnected"); + }); + + it("requires an explicit debt choice before opening a repayment review", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Choose debt to repay" })); + + const chooser = screen.getByRole("dialog", { name: "Choose debt to repay" }); + expect(chooser).toHaveTextContent( + "TezFin will not select or batch debts automatically", + ); + + const usdTzChoice = within(chooser).getByRole("button", { + name: /USDtz.*2,000 USDtz/s, + }); + fireEvent.click(usdTzChoice); + + expect( + screen.getByRole("dialog", { name: "Repay USDtz" }), + ).toBeInTheDocument(); + expect( + screen.getByText("Offline fixture — wallet and RPC access are disabled"), + ).toBeInTheDocument(); + }); + + it("rejects a malformed decimal amount instead of accepting its valid prefix", () => { + render(); + + const supplyBoard = screen.getByRole("region", { + name: "Assets to Supply", + }); + fireEvent.click( + within(supplyBoard).getAllByRole("button", { name: "Supply" })[0], + ); + + const dialog = screen.getByRole("dialog", { name: "Supply XTZ" }); + const input = within(dialog).getByRole("textbox", { + name: "Amount in XTZ", + }); + + fireEvent.change(input, { target: { value: "1.2.3" } }); + + expect(input).toHaveValue("1.2.3"); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(within(dialog).getByRole("alert")).toHaveTextContent( + "Enter a valid decimal amount", + ); + expect( + within(dialog).getByRole("button", { name: "Check amount" }), + ).toBeDisabled(); + + fireEvent.change(input, { target: { value: "1.23" } }); + + expect(input).toHaveAttribute("aria-invalid", "false"); + expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument(); + expect( + within(dialog).getByRole("button", { name: "Review supply" }), + ).toBeEnabled(); + }); + + it("keeps wallet connection local to the offline fixture", () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + window.history.replaceState({}, "", "/?state=disconnected"); + + render(); + fireEvent.click(screen.getAllByRole("button", { name: "Connect wallet" })[0]); + + expect( + screen.getByRole("status"), + ).toHaveTextContent("Wallet access is disabled in this offline design preview."); + expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 117e35b8..9a49415f 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,18 +1,873 @@ +import { + ArrowSquareOut, + CaretDown, + CaretRight, + CheckCircle, + Copy, + List, + ShieldCheck, + WarningCircle, + Wallet, + X, +} from "@phosphor-icons/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { calculateTotals, scenarios } from "./fixtures"; +import type { + AssetId, + MarketFixture, + OperationStep, + ScenarioId, + TransactionAction, + TransactionDraft, +} from "./types"; + +const scenarioOrder: ScenarioId[] = [ + "disconnected", + "fresh", + "supplying", + "active", + "near-liquidation", +]; + +const currency = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +const compactCurrency = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + notation: "compact", + maximumFractionDigits: 2, +}); + +const decimalInputPattern = /^(?:\d+(?:\.\d*)?|\.\d+)$/; + +function formatAmount(value: number): string { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: value < 1 ? 6 : value < 100 ? 4 : 2, + }).format(value); +} + +function parseFixtureAmount(value: string): number | null { + if (!decimalInputPattern.test(value)) return null; + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function riskBand(percent: number): "normal" | "attention" | "high" | "critical" { + if (percent >= 95) return "critical"; + if (percent >= 85) return "high"; + if (percent >= 70) return "attention"; + return "normal"; +} + +function initialScenario(): ScenarioId { + const value = new URLSearchParams(window.location.search).get("state") as ScenarioId | null; + return value && scenarioOrder.includes(value) ? value : "near-liquidation"; +} + +function operationSteps(action: TransactionAction, market: MarketFixture): OperationStep[] { + const fToken = `f${market.name}`; + const accrue: OperationStep = { + label: "Accrue interest", + detail: "Refresh all 5 configured TezFin markets", + }; + const liquidity: OperationStep = { + label: "Refresh account liquidity", + detail: "Comptroller · updateAccountLiquidityWithView", + }; + + if (action === "Supply" || action === "Repay") { + const steps: OperationStep[] = [accrue]; + + if (market.standard === "FA1.2") { + steps.push( + { label: "Reset allowance", detail: `${market.name} · approve 0` }, + { label: "Grant exact allowance", detail: `${market.name} → ${fToken}` }, + ); + } + + if (market.standard === "FA1.2 packed") { + steps.push({ + label: "Permission path blocked", + detail: "Packed FA1.2 approval requires contract verification", + }); + } + + if (market.standard === "FA2") { + steps.push({ + label: "Add temporary operator", + detail: `${market.name} → ${fToken}`, + }); + } + + steps.push({ + label: action === "Supply" ? "Supply asset" : "Repay debt", + detail: `${fToken} · ${action === "Supply" ? "mint" : "repayBorrow"}`, + }); + + if (market.standard === "FA2") { + steps.push({ + label: "Remove temporary operator", + detail: `${market.name} → ${fToken}`, + }); + } + + return steps; + } + + if (action === "Withdraw") { + return [ + liquidity, + { label: "Withdraw supplied asset", detail: `${fToken} · redeem` }, + ]; + } + + if (action === "Borrow") { + return [ + liquidity, + { label: "Borrow asset", detail: `${fToken} · borrow` }, + ]; + } + + return [ + liquidity, + { + label: action, + detail: `Comptroller · ${action === "Enable collateral" ? "enterMarkets" : "exitMarket"}`, + }, + ]; +} + +function actionSide(action: TransactionAction): "supply" | "borrow" | "collateral" { + if (action === "Supply" || action === "Withdraw") return "supply"; + if (action === "Borrow" || action === "Repay") return "borrow"; + return "collateral"; +} + +function AssetIdentity({ market, fToken = false }: { market: MarketFixture; fToken?: boolean }) { + return ( +
+ + + {market.name} + {fToken ? `f${market.name} · ${currency.format(market.price)}` : `${market.subtitle} · ${currency.format(market.price)}`} + +
+ ); +} + +function RiskMeter({ percent, compact = false }: { percent: number; compact?: boolean }) { + const shown = Math.min(Math.max(percent, 0), 100); + const band = riskBand(percent); + + return ( +
+ +
+ ); +} + +function PositionPassbook({ + scenario, + markets, + onRepay, + onAddCollateral, +}: { + scenario: ScenarioId; + markets: MarketFixture[]; + onRepay: () => void; + onAddCollateral: () => void; +}) { + const fixture = scenarios[scenario]; + const totals = calculateTotals(markets); + const band = riskBand(totals.limitUsedPercent); + const suppliedMarkets = markets.filter((market) => market.supplied > 0).length; + const borrowedMarkets = markets.filter((market) => market.borrowed > 0).length; + + if (!fixture.connected) { + return ( +
+ +
+

Your position

+

Your TezFin passbook

+

Connect a Tezos wallet to see supplied assets, open borrows, and liquidation headroom in one place.

+ + Offline preview — wallet access is disabled +
+
+ ); + } + + return ( + <> + {band === "critical" && ( +
+ + + Your position is close to liquidation + Only {currency.format(totals.headroomUsd)} of borrowing headroom remains in this preview. + + + + + +
+ )} + +
+ +
+
+

Supplied

+

{currency.format(totals.suppliedUsd)}

+

across {suppliedMarkets} market{suppliedMarkets === 1 ? "" : "s"}

+
+
+

Estimated net rate

+

{totals.estimatedNetRate >= 0 ? "+" : "−"}{Math.abs(totals.estimatedNetRate).toFixed(2)}%

+

variable · fixture calculation

+
+
+

Borrowed

+

{currency.format(totals.borrowedUsd)}

+

{borrowedMarkets === 0 ? "no open lines" : `${borrowedMarkets} open line${borrowedMarkets === 1 ? "" : "s"}`}

+
+
+ +
+
+ Borrow limit used {totals.limitUsedPercent.toFixed(1)}% + {totals.borrowLimitUsd > 0 ? `${currency.format(totals.headroomUsd)} headroom` : "Supply collateral to unlock borrowing"} +
+ +
+ Collateral {currency.format(totals.collateralUsd)} + Borrow limit {currency.format(totals.borrowLimitUsd)} +
+
+
+ + ); +} + +type BoardKind = "supplying" | "borrowing" | "supply-market" | "borrow-market"; + +function MarketBoard({ + kind, + markets, + connected, + onOpen, + onCollateral, +}: { + kind: BoardKind; + markets: MarketFixture[]; + connected: boolean; + onOpen: (market: MarketFixture, action: TransactionAction) => void; + onCollateral: (market: MarketFixture) => void; +}) { + const isSupply = kind === "supplying" || kind === "supply-market"; + const isPosition = kind === "supplying" || kind === "borrowing"; + const title = { + supplying: "Supplying", + borrowing: "Borrowing", + "supply-market": "Assets to Supply", + "borrow-market": "Assets to Borrow", + }[kind]; + const action: TransactionAction = { + supplying: "Withdraw", + borrowing: "Repay", + "supply-market": "Supply", + "borrow-market": "Borrow", + }[kind] as TransactionAction; + const boardMarkets = kind === "supplying" + ? markets.filter((market) => market.supplied > 0) + : kind === "borrowing" + ? markets.filter((market) => market.borrowed > 0) + : markets; + const total = boardMarkets.reduce((sum, market) => { + const amount = kind === "borrowing" ? market.borrowed : kind === "supplying" ? market.supplied : 0; + return sum + amount * market.price; + }, 0); + + return ( +
+
+

{title}

+ {isPosition && {boardMarkets.length} asset{boardMarkets.length === 1 ? "" : "s"} {currency.format(total)}} +
+ + {boardMarkets.length === 0 ? ( +
+ {kind === "supplying" + ? "Nothing supplied yet — choose an asset below to preview a deposit." + : "No open lines — borrowing stays empty until collateral is supplied."} +
+ ) : ( +
+ + {boardMarkets.map((market) => { + const balance = kind === "supplying" ? market.supplied : market.borrowed; + const disabled = !connected && !isPosition; + + return ( +
+ + {isPosition ? ( +
+ {(isSupply ? market.supplyApy : market.borrowApy).toFixed(2)}% +
+ ) : ( +
+ {kind === "supply-market" ? formatAmount(market.walletBalance) : compactCurrency.format(market.liquidityUsd)} + {kind === "supply-market" && {currency.format(market.walletBalance * market.price)}} +
+ )} + {isPosition ? ( +
+ {formatAmount(balance)} {market.name} + {currency.format(balance * market.price)} +
+ ) : ( +
+ {(isSupply ? market.supplyApy : market.borrowApy).toFixed(2)}% +
+ )} + {kind === "supplying" && ( +