From 2c4a4146450919a958835a0154708dc19dff6daa Mon Sep 17 00:00:00 2001 From: sourovahmad Date: Wed, 15 Jul 2026 13:20:04 +0200 Subject: [PATCH 1/8] added: new designs from html --- BunkerCash Platform (1).html | 181 +++ ts/apps/admin/lib/admin-auth-nonce.test.ts | 39 + ts/apps/admin/lib/admin-auth-nonce.ts | 120 +- ts/apps/admin/next.config.ts | 8 +- ts/apps/admin/wrangler.dev.jsonc | 30 + ts/apps/web/app/api/pool-data/route.ts | 46 +- ts/apps/web/app/blocked/page.tsx | 55 +- ts/apps/web/app/buy/page.tsx | 9 +- ts/apps/web/app/error.tsx | 45 +- ts/apps/web/app/imprint/page.tsx | 77 +- ts/apps/web/app/information/page.tsx | 545 ++++---- ts/apps/web/app/layout.tsx | 19 +- ts/apps/web/app/not-found.tsx | 38 +- ts/apps/web/app/page.tsx | 854 +++++-------- ts/apps/web/app/pool/page.tsx | 514 ++++---- ts/apps/web/app/providers.tsx | 48 +- ts/apps/web/app/sell/page.tsx | 11 +- ts/apps/web/app/support/page.tsx | 157 +-- ts/apps/web/app/wallet/WalletPageClient.tsx | 1093 +++++++++++------ .../web/components/BuyPrimaryInterface.tsx | 481 ++++---- ts/apps/web/components/SupportRequestForm.tsx | 131 +- ts/apps/web/components/TradeInterface.tsx | 78 -- ts/apps/web/components/TradePageContent.tsx | 126 +- ts/apps/web/components/WithdrawInterface.tsx | 727 ++++------- ts/apps/web/components/design/Disclaimer.tsx | 21 + .../web/components/design/PageContainer.tsx | 22 + .../components/design/PriceHistoryChart.tsx | 271 ++++ ts/apps/web/components/design/icons.tsx | 270 ++++ ts/apps/web/components/design/primitives.tsx | 228 ++++ ts/apps/web/components/layout/EnvNotice.tsx | 20 + ts/apps/web/components/layout/Footer.tsx | 47 +- ts/apps/web/components/layout/Layout.tsx | 22 +- ts/apps/web/components/layout/MobileNav.tsx | 41 + ts/apps/web/components/layout/SiteHeader.tsx | 225 ++++ ts/apps/web/components/layout/StatusRail.tsx | 117 ++ ts/apps/web/components/layout/nav.ts | 18 + .../web/components/trade/ContextColumn.tsx | 258 ++++ ts/apps/web/components/trade/ReviewSheet.tsx | 314 +++++ .../web/components/trade/composerParts.tsx | 230 ++++ .../components/wallet/ConnectWalletModal.tsx | 179 +++ ts/apps/web/fonts/GeneralSans-Bold.woff2 | Bin 0 -> 21180 bytes ts/apps/web/fonts/GeneralSans-Medium.woff2 | Bin 0 -> 22904 bytes ts/apps/web/fonts/GeneralSans-Regular.woff2 | Bin 0 -> 23084 bytes ts/apps/web/fonts/GeneralSans-Semibold.woff2 | Bin 0 -> 23092 bytes ts/apps/web/hooks/useCancelClaim.ts | 216 ++++ ts/apps/web/hooks/usePriceHistory.ts | 2 +- ts/apps/web/hooks/useUsdcBalance.ts | 54 + ts/apps/web/index.css | 320 +++-- ts/apps/web/lib/explorer.ts | 29 + .../lib/sendAndConfirmWalletTransaction.ts | 4 + ts/apps/web/lib/support-requests.test.ts | 12 + ts/apps/web/lib/support-requests.ts | 10 +- ts/apps/web/lib/ui-atoms.ts | 4 + ts/apps/web/next.config.ts | 8 +- ts/apps/web/package.json | 2 +- ts/apps/web/providers/SolanaProvider.tsx | 12 +- ts/apps/web/tailwind.config.ts | 86 +- ts/apps/web/tsconfig.tsbuildinfo | 2 +- ts/apps/web/wrangler.dev.jsonc | 30 + ts/apps/web/wrangler.jsonc | 17 +- 60 files changed, 5721 insertions(+), 2802 deletions(-) create mode 100644 BunkerCash Platform (1).html create mode 100644 ts/apps/admin/lib/admin-auth-nonce.test.ts create mode 100644 ts/apps/admin/wrangler.dev.jsonc delete mode 100644 ts/apps/web/components/TradeInterface.tsx create mode 100644 ts/apps/web/components/design/Disclaimer.tsx create mode 100644 ts/apps/web/components/design/PageContainer.tsx create mode 100644 ts/apps/web/components/design/PriceHistoryChart.tsx create mode 100644 ts/apps/web/components/design/icons.tsx create mode 100644 ts/apps/web/components/design/primitives.tsx create mode 100644 ts/apps/web/components/layout/EnvNotice.tsx create mode 100644 ts/apps/web/components/layout/MobileNav.tsx create mode 100644 ts/apps/web/components/layout/SiteHeader.tsx create mode 100644 ts/apps/web/components/layout/StatusRail.tsx create mode 100644 ts/apps/web/components/layout/nav.ts create mode 100644 ts/apps/web/components/trade/ContextColumn.tsx create mode 100644 ts/apps/web/components/trade/ReviewSheet.tsx create mode 100644 ts/apps/web/components/trade/composerParts.tsx create mode 100644 ts/apps/web/components/wallet/ConnectWalletModal.tsx create mode 100644 ts/apps/web/fonts/GeneralSans-Bold.woff2 create mode 100644 ts/apps/web/fonts/GeneralSans-Medium.woff2 create mode 100644 ts/apps/web/fonts/GeneralSans-Regular.woff2 create mode 100644 ts/apps/web/fonts/GeneralSans-Semibold.woff2 create mode 100644 ts/apps/web/hooks/useCancelClaim.ts create mode 100644 ts/apps/web/hooks/useUsdcBalance.ts create mode 100644 ts/apps/web/lib/explorer.ts create mode 100644 ts/apps/web/lib/ui-atoms.ts create mode 100644 ts/apps/web/wrangler.dev.jsonc diff --git a/BunkerCash Platform (1).html b/BunkerCash Platform (1).html new file mode 100644 index 0000000..8229f97 --- /dev/null +++ b/BunkerCash Platform (1).html @@ -0,0 +1,181 @@ + + + + + Bundled Page + + + + +
+ + + + + + +
+
Unpacking...
+ + + + + + + + + + \ No newline at end of file diff --git a/ts/apps/admin/lib/admin-auth-nonce.test.ts b/ts/apps/admin/lib/admin-auth-nonce.test.ts new file mode 100644 index 0000000..2727cd0 --- /dev/null +++ b/ts/apps/admin/lib/admin-auth-nonce.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { EMPTY_BODY_SHA256 } from "./admin-auth-message"; + +vi.mock("@opennextjs/cloudflare", () => ({ + getCloudflareContext: vi.fn(), +})); + +const { consumeAdminAuthNonce, issueAdminAuthChallenge } = await import( + "./admin-auth-nonce" +); + +describe("admin auth nonce local fallback", () => { + beforeEach(() => { + vi.stubEnv("NODE_ENV", "development"); + vi.mocked(getCloudflareContext).mockResolvedValue({ env: {} } as never); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it("issues and consumes a nonce when the local Durable Object binding is absent", async () => { + const challenge = await issueAdminAuthChallenge({ + method: "GET", + route: "/api/geoblocking", + bodyHash: EMPTY_BODY_SHA256, + }); + + await expect(consumeAdminAuthNonce(challenge)).resolves.toEqual({ + ok: true, + }); + await expect(consumeAdminAuthNonce(challenge)).resolves.toEqual({ + ok: false, + error: "Admin authorization nonce was not issued", + }); + }); +}); diff --git a/ts/apps/admin/lib/admin-auth-nonce.ts b/ts/apps/admin/lib/admin-auth-nonce.ts index 3f5eae7..2344ed6 100644 --- a/ts/apps/admin/lib/admin-auth-nonce.ts +++ b/ts/apps/admin/lib/admin-auth-nonce.ts @@ -1,5 +1,6 @@ import { getCloudflareContext } from "@opennextjs/cloudflare"; import { + ADMIN_AUTH_SIGNATURE_TTL_MS, EMPTY_BODY_SHA256, normalizeAdminAuthMethod, type AdminAuthRequestChallenge, @@ -24,6 +25,13 @@ export interface AdminAuthNonceResult { error?: string; } +interface LocalAdminAuthChallenge extends AdminAuthRequestChallenge { + expiresAt: number; + consumedAt?: number; +} + +const localChallenges = new Map(); + function toHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join( "", @@ -90,6 +98,62 @@ async function getAdminAuthNonceNamespace(): Promise { return namespace as AdminAuthNonceNamespace; } +function shouldUseLocalNonceStore(error: unknown): boolean { + return ( + process.env.NODE_ENV === "development" && + error instanceof Error && + error.message.includes(`"${ADMIN_AUTH_NONCE_BINDING}"`) + ); +} + +function cleanupLocalChallenges(now = Date.now()): void { + for (const [nonce, challenge] of localChallenges) { + if (challenge.expiresAt < now || challenge.consumedAt) { + localChallenges.delete(nonce); + } + } +} + +function issueLocalChallenge( + challenge: AdminAuthRequestChallenge, +): AdminAuthRequestChallenge { + cleanupLocalChallenges(); + localChallenges.set(challenge.nonce, { + ...challenge, + expiresAt: Date.parse(challenge.issuedAt) + ADMIN_AUTH_SIGNATURE_TTL_MS, + }); + + return challenge; +} + +function consumeLocalChallenge( + challenge: AdminAuthRequestChallenge, +): AdminAuthNonceResult { + cleanupLocalChallenges(); + const stored = localChallenges.get(challenge.nonce); + if (!stored) { + return { ok: false, error: "Admin authorization nonce was not issued" }; + } + + if ( + stored.method !== challenge.method || + stored.route !== challenge.route || + stored.bodyHash !== challenge.bodyHash || + stored.issuedAt !== challenge.issuedAt + ) { + return { ok: false, error: "Admin authorization nonce mismatch" }; + } + + if (Date.now() > stored.expiresAt) { + localChallenges.delete(challenge.nonce); + return { ok: false, error: "Admin authorization nonce expired" }; + } + + stored.consumedAt = Date.now(); + localChallenges.delete(challenge.nonce); + return { ok: true }; +} + async function fetchNonceObject( nonce: string, path: string, @@ -112,13 +176,22 @@ export async function issueAdminAuthChallenge( nonce, }; - const response = await fetchNonceObject(nonce, "/issue", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(challenge), - }); + let response: Response; + try { + response = await fetchNonceObject(nonce, "/issue", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(challenge), + }); + } catch (error) { + if (shouldUseLocalNonceStore(error)) { + return issueLocalChallenge(challenge); + } + + throw error; + } if (!response.ok) { throw new Error(`Failed to issue admin authorization nonce (${response.status})`); @@ -131,17 +204,28 @@ export async function consumeAdminAuthNonce( challenge: AdminAuthRequestChallenge, ): Promise { const normalized = normalizeChallengeRequest(challenge); - const response = await fetchNonceObject(challenge.nonce, "/consume", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ - ...normalized, - issuedAt: challenge.issuedAt, - nonce: challenge.nonce, - } satisfies AdminAuthRequestChallenge), - }); + const normalizedChallenge = { + ...normalized, + issuedAt: challenge.issuedAt, + nonce: challenge.nonce, + } satisfies AdminAuthRequestChallenge; + let response: Response; + + try { + response = await fetchNonceObject(challenge.nonce, "/consume", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(normalizedChallenge), + }); + } catch (error) { + if (shouldUseLocalNonceStore(error)) { + return consumeLocalChallenge(normalizedChallenge); + } + + throw error; + } if (response.ok) { return { ok: true }; diff --git a/ts/apps/admin/next.config.ts b/ts/apps/admin/next.config.ts index eeb59ee..2328e3d 100644 --- a/ts/apps/admin/next.config.ts +++ b/ts/apps/admin/next.config.ts @@ -1,7 +1,13 @@ import type { NextConfig } from "next"; import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; -initOpenNextCloudflareForDev(); +const currentDir = path.dirname(fileURLToPath(import.meta.url)); + +initOpenNextCloudflareForDev({ + configPath: path.join(currentDir, "wrangler.dev.jsonc"), +}); const nextConfig: NextConfig = { reactStrictMode: true, diff --git a/ts/apps/admin/wrangler.dev.jsonc b/ts/apps/admin/wrangler.dev.jsonc new file mode 100644 index 0000000..44b3bf8 --- /dev/null +++ b/ts/apps/admin/wrangler.dev.jsonc @@ -0,0 +1,30 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "main": "worker.js", + "name": "bunkercash-admin-next-dev", + "compatibility_date": "2024-09-23", + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + "vars": { + "NEXT_PUBLIC_SOLANA_RPC_URL": "https://api.devnet.solana.com", + "NEXT_PUBLIC_SOLANA_CLUSTER": "devnet", + "NEXT_PUBLIC_CLUSTER": "devnet", + "NEXT_PUBLIC_USDC_MINT": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "NEXT_PUBLIC_TOKEN_METADATA_URI": "https://bunkercash-web.bunkercoin.workers.dev/bunkercash-metadata.json", + "NEXT_PUBLIC_SQUADS_MULTISIG_PUBKEY": "HEzTX4LNQ7cjrXTt1vKXyEZGtc94TKRUc9kWNFexJSGz" + }, + "kv_namespaces": [ + { + "binding": "GEOBLOCKING_KV", + "id": "9abcd5888b5c436fba6f445f8ba68e98", + "preview_id": "a6f9582a6f8444bea09b37128e00bb17" + } + ], + "d1_databases": [ + { + "binding": "METRICS_DB", + "database_id": "97cc29d2-b9e4-4430-83ce-bb1272e1d629", + "database_name": "bunkercash-metrics", + "migrations_dir": "../../packages/metrics-data/migrations" + } + ] +} diff --git a/ts/apps/web/app/api/pool-data/route.ts b/ts/apps/web/app/api/pool-data/route.ts index 2b35fb9..50acb1d 100644 --- a/ts/apps/web/app/api/pool-data/route.ts +++ b/ts/apps/web/app/api/pool-data/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; import { cachedFetch } from "@bunkercash/cloudflare-kv"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { createMetricsClient, getLatestSnapshot } from "@bunkercash/metrics-data"; import { fetchPoolData, type PoolDataResponse } from "@/lib/solana-server"; export const runtime = "nodejs"; @@ -8,6 +10,35 @@ const BINDING = "GEOBLOCKING_KV"; const CACHE_KEY = "cache:pool_data"; const TTL_SECONDS = 30; +async function d1Fallback(): Promise { + try { + const ctx = await getCloudflareContext(); + const db = (ctx.env as Record).METRICS_DB as D1Database | undefined; + if (!db) return null; + const client = createMetricsClient(db); + try { + const row = await getLatestSnapshot(client); + if (!row) return null; + return { + tokenPrice: row.pricePerToken ?? row.tokenPrice ?? 1, + totalSupplyRaw: row.totalSupply ?? 0, + circulatingSupplyRaw: row.totalSupply ?? 0, + escrowBunkercashRaw: 0, + navUsdcRaw: row.navUsdc ?? 0, + pendingClaimsUsdcRaw: row.pendingClaimsUsdc ?? 0, + treasuryUsdcRaw: row.treasuryUsdc ?? null, + pricePerToken: row.pricePerToken ?? row.tokenPrice ?? 1, + adminWallet: "", + ts: Date.now(), + }; + } finally { + await client.$disconnect(); + } + } catch { + return null; + } +} + // Public read-only: on-chain pool state, KV-cached. export async function GET() { const start = performance.now(); @@ -31,7 +62,20 @@ export async function GET() { }, }); } catch (e: unknown) { - console.error("[pool-data] Failed:", e instanceof Error ? e.message : e); + console.error("[pool-data] RPC failed, trying D1 fallback:", e instanceof Error ? e.message : e); + + const fallback = await d1Fallback(); + if (fallback) { + const elapsed = performance.now() - start; + return NextResponse.json(fallback, { + headers: { + "Cache-Control": "public, s-maxage=60, stale-while-revalidate=120", + "X-Cache": "D1-FALLBACK", + "X-Response-Time": `${elapsed.toFixed(1)}ms`, + }, + }); + } + return NextResponse.json( { error: "Failed to fetch pool data" }, { status: 500 }, diff --git a/ts/apps/web/app/blocked/page.tsx b/ts/apps/web/app/blocked/page.tsx index 80f95d7..b20966f 100644 --- a/ts/apps/web/app/blocked/page.tsx +++ b/ts/apps/web/app/blocked/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; -import { ShieldAlert } from "lucide-react"; +import { WarnIcon } from "@/components/design/icons"; +import { Disclaimer } from "@/components/design/Disclaimer"; export const metadata = { title: "Access Restricted | BunkerCash", @@ -7,33 +8,33 @@ export const metadata = { export default function BlockedPage() { return ( -
-
-
- -
-

- Access Restricted -

-
-

BunkerCash is not available in your jurisdiction.

-

- Access to protocol functions has been restricted based on - jurisdictional and eligibility requirements. -

-

No offer or solicitation is made where unlawful.

-
-
-
- If you believe this restriction is incorrect, contact support. - Additional verification may be required. +
+ +
+
+ + + +

Access restricted

+
+

BunkerCash is not available in your jurisdiction.

+

+ Access to protocol functions has been restricted based on + jurisdictional and eligibility requirements. +

+

No offer or solicitation is made where unlawful.

+
+
+ + If you believe this restriction is incorrect, contact support. + + + Contact support +
- - Contact Support -
diff --git a/ts/apps/web/app/buy/page.tsx b/ts/apps/web/app/buy/page.tsx index 6aca682..d0e0fcf 100644 --- a/ts/apps/web/app/buy/page.tsx +++ b/ts/apps/web/app/buy/page.tsx @@ -1,12 +1,5 @@ import { TradePageContent } from "@/components/TradePageContent"; export default function BuyPage() { - return ( - - ); + return ; } diff --git a/ts/apps/web/app/error.tsx b/ts/apps/web/app/error.tsx index dae1a3a..4b8a074 100644 --- a/ts/apps/web/app/error.tsx +++ b/ts/apps/web/app/error.tsx @@ -1,27 +1,34 @@ -'use client' +"use client"; -export default function Error({ +import { Disclaimer } from "@/components/design/Disclaimer"; + +export default function ErrorPage({ reset, }: { - error: Error & { digest?: string } - reset: () => void + error: Error & { digest?: string }; + reset: () => void; }) { return ( -
-
-

500

-

Something went wrong!

-

- An error occurred while processing your request. -

- +
+ +
+
+ + 500 + +

Something went wrong

+

+ An error occurred while processing your request. +

+ +
- ) + ); } - diff --git a/ts/apps/web/app/imprint/page.tsx b/ts/apps/web/app/imprint/page.tsx index e69544e..58d2703 100644 --- a/ts/apps/web/app/imprint/page.tsx +++ b/ts/apps/web/app/imprint/page.tsx @@ -1,53 +1,46 @@ import { Layout } from "@/components/layout/Layout"; -import { FileText, Mail } from "lucide-react"; +import { PageContainer } from "@/components/design/PageContainer"; +import { SectionCard, CardHeader } from "@/components/design/primitives"; -const Imprint = () => { +export default function ImprintPage() { return ( -
-
-
-

- Imprint -

-

- Provider information and legal contact details. -

-
+ +
+

Imprint

+ + Provider information and legal contact details. + +
-
-
-
-
- -
-

Provider

-
-
-

BunkerCash

-

Office 2207, Boulevard Plaza Tower 1

-

Sheikh Mohammed Bin Rashid Boulevard

-

Downtown Dubai, P.O. Box 334036

-

Dubai, United Arab Emirates

-
+
+ + +
+ BunkerCash + Office 2207, Boulevard Plaza Tower 1 + Sheikh Mohammed Bin Rashid Boulevard + Downtown Dubai, P.O. Box 334036 + Dubai, United Arab Emirates
+
-
-
-
- -
-

Contact

-
-
-

Email: [contact@example.com]

-
+ + +
+ + Email:{" "} + + contact@example.com + +
-
+
-
+ ); -}; - -export default Imprint; +} diff --git a/ts/apps/web/app/information/page.tsx b/ts/apps/web/app/information/page.tsx index 9520c79..69e3951 100644 --- a/ts/apps/web/app/information/page.tsx +++ b/ts/apps/web/app/information/page.tsx @@ -1,230 +1,333 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; import { Layout } from "@/components/layout/Layout"; -import { - BookOpen, - TrendingUp, - ArrowLeftRight, - Coins, - AlertTriangle, - Scale, -} from "lucide-react"; - -const Information = () => { +import { PageContainer } from "@/components/design/PageContainer"; +import { SectionCard } from "@/components/design/primitives"; +import { WarnIcon } from "@/components/design/icons"; +import { cn } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// Data +// --------------------------------------------------------------------------- +type Section = { id: string; title: string }; + +const SECTIONS: Section[] = [ + { id: "overview", title: "1. Overview" }, + { id: "mechanics", title: "2. Protocol Mechanics" }, + { id: "token-limitations", title: "3. Token Limitations" }, + { id: "no-ownership", title: "4. No Ownership / No Revenue Rights" }, + { id: "settlement-risks", title: "5. Settlement and Liquidity Risks" }, + { id: "technical-risks", title: "6. Technical Risks" }, + { id: "regulatory", title: "7. Regulatory and Jurisdictional Restrictions" }, + { id: "legal-disclaimer", title: "8. Legal Disclaimer" }, +]; + +// --------------------------------------------------------------------------- +// TOC sidebar (sticky, desktop only) +// --------------------------------------------------------------------------- +function TocSidebar({ activeId }: { activeId: string }) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Section wrapper + typography helpers +// --------------------------------------------------------------------------- +function ContentSection({ + id, + title, + children, +}: { + id: string; + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function P({ children }: { children: React.ReactNode }) { + return ( +

{children}

+ ); +} + +function Strong({ children }: { children: React.ReactNode }) { + return {children}; +} + +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- +export default function InformationPage() { + const [activeId, setActiveId] = useState(SECTIONS[0].id); + const observerRef = useRef(null); + + const setupObserver = useCallback(() => { + observerRef.current?.disconnect(); + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + setActiveId(entry.target.id); + break; + } + } + }, + { rootMargin: "-140px 0px -60% 0px", threshold: 0 }, + ); + for (const s of SECTIONS) { + const el = document.getElementById(s.id); + if (el) observer.observe(el); + } + observerRef.current = observer; + }, []); + + useEffect(() => { + setupObserver(); + return () => observerRef.current?.disconnect(); + }, [setupObserver]); + return ( -
-
- {/* Header */} -
-

- How BunkerCash works -

-

- A plain-language guide to buying, selling, and how the price is - set. Risks and limitations are summarized at the bottom. -

+ +
+

+ Documentation +

+ + Documentation, restrictions, and risk disclosures for BunkerCash. + +
+ + {/* Important notice banner */} +
+ +
+ Please Read Carefully + + This page contains important information about protocol + restrictions, token limitations, and associated risks. +
+
+ +
+ + + +
+ {/* 1. Overview */} + +

+ BunkerCash is an access-restricted digital token protocol. The + protocol does not provide ownership in assets, rights to + revenue, guaranteed liquidity, or guaranteed future value. +

+

+ Protocol functions are available only in eligible + jurisdictions and subject to applicable restrictions. Access + may be limited, suspended, or unavailable at any time without + notice. +

+
+ + {/* 2. Protocol Mechanics */} + +

+ The protocol enables eligible users to acquire tokens through + a defined interface, subject to protocol-defined parameters + and access restrictions. Token pricing is determined by + protocol-defined reference rates derived from on-chain state. +

+

+ Users may submit settlement requests to remove tokens from + circulation. Submitted tokens are permanently removed and + cannot be recovered, traded, or transferred. Settlement of + requests depends entirely on available protocol liquidity and + is not guaranteed in timing or amount. +

+

+ Protocol interactions may be unavailable or delayed due to + network conditions, maintenance, or other factors outside + user control. +

+
-
- {/* What it is */} -
-
-
- -
-

What BunkerCash is

-
-
-

- BunkerCash (BNKR) is a digital token you buy with USDC and - sell back for USDC, all on the Solana blockchain. Its price is - set by the protocol from on-chain pool data — there is no order - book and no third-party market maker. -

-

- Holding BNKR is not ownership of any company, asset, or revenue - stream. It is a digital token whose value can rise, fall, or - reach zero. -

-
-
- - {/* Pricing */} -
-
-
- -
-

How the price works

-
-
-

- The price is a reference rate{" "} - shown as USDC per token. It is - calculated on-chain as: -

-
- reference rate = available pool value (NAV) ÷ circulating token supply -
-

- The rate moves when the pool’s Net Asset Value (NAV) or - the circulating supply changes — for example when people buy or - sell, when sell requests settle, or when the pool’s NAV - is updated. Both buying and selling use this same protocol - rate, so there is no spread between a “buy price” - and a “sell price.” -

-
-
- - {/* Buying & selling */} -
-
-
- -
-

Buying and selling

-
-
-

- Buying is instant.{" "} - You send USDC and receive newly minted BNKR at the current - reference rate. Buys may be subject to per-wallet purchase - limits and regional eligibility checks. -

-

- Selling is a request, not an instant swap.{" "} - When you sell, your BNKR is locked in the pool and a sell - request is created. You receive USDC when a{" "} - settlement{" "} - runs, which pays requests from the pool’s available - (“liquid”) USDC. If liquidity is limited, a request - may settle partially and the rest pays out as more liquidity - becomes available. -

-

- You can track a sell under{" "} - Transactions{" "} - or History, - where it shows as Pending, Partially settled, or Settled. While - a request is still pending you can{" "} - cancel it - to get your locked BNKR back. -

-

- BunkerCash trades in USDC only{" "} - — there is no buying or selling with SOL. You only need a small - amount of SOL in your wallet to pay Solana network fees. -

-
-
- - {/* What happens to tokens */} -
-
-
- -
-

What happens to your tokens

-
-
    -
  • - - - Buy: new - BNKR is minted to your wallet and circulating supply - increases. - -
  • -
  • - - - Sell request:{" "} - your BNKR is moved into pool escrow (locked, not yet - destroyed) and removed from circulating supply. - -
  • -
  • - - - Settled:{" "} - the settled portion of escrowed BNKR is permanently burned - and you receive the corresponding USDC. - -
  • -
  • - - - Cancelled:{" "} - escrowed BNKR is returned to your wallet and rejoins - circulating supply. - -
  • -
-
- - {/* Risks */} -
-
-
- -
-

Risks & limitations

-
-
    -
  • - - - No guaranteed liquidity or timing.{" "} - Sells settle only from available pool liquidity. You may not - be able to convert tokens quickly, fully, or at all. - -
  • -
  • - - - Value can fall to zero.{" "} - The reference rate can decrease substantially. Only use funds - you can afford to lose. - -
  • -
  • - - - Technical risk.{" "} - Smart contracts and the Solana network can have bugs, - outages, or congestion that delay or block actions. - -
  • -
  • - - - Access can be restricted.{" "} - Availability depends on your jurisdiction and eligibility, - and can be limited or suspended at any time. - -
  • -
-

- This page is informational only and is not financial, investment, - legal, or tax advice, nor an offer or solicitation. All - information is provided “as is.” By using BunkerCash - you accept these risks. See the{" "} - - imprint - {" "} - for legal details. -

-
- -
- - Transparent, on-chain, and protocol-defined. + {/* 3. Token Limitations */} + +

+ BunkerCash tokens are digital protocol tokens only. They do + not represent any share, equity, debt, security, or other + financial instrument. Holding tokens does not create any + contractual relationship or entitlement to benefits, profits, + or distributions of any kind. +

+

+ There is no guarantee of future value. Token value may + decrease substantially or become zero with no guarantee of + recovery. Displayed interface values are informational only. +

+
+ + {/* 4. No Ownership / No Revenue Rights */} + +

+ Tokens confer no ownership in real estate or other assets, no + equity rights, and no revenue rights. +

+

+ Real-world activities, including real-world assets, are not + represented on-chain in any form. There is no direct or + indirect connection between token holdings and any physical, + financial, or business assets. The token exists solely as a + digital instrument on the blockchain, completely separate from + any off-chain operations. +

+

+ No content on this interface or in protocol documentation + implies or creates any ownership interest, profit-sharing + arrangement, or revenue entitlement. +

+
+ + {/* 5. Settlement and Liquidity Risks */} + +

+ + There is no guaranteed liquidity and no guaranteed + settlement timing. + +

+

+ Settlement of requests depends entirely on available protocol + liquidity, which is discretionary and may change without + notice. Liquidity may be insufficient to fulfill all pending + requests. No timeline or schedule for settlements exists or is + implied. +

+

+ You may not be able to convert tokens at any price or at all. + Only interact with the protocol using amounts you can afford + to lose completely. +

+

+ This website does not provide financial, investment, legal, or + tax advice. Nothing on this website should be construed as a + recommendation to purchase, sell, or hold any token. All + information is provided “as is” without + warranties of any kind. You should consult with qualified + professional advisors before making any decisions related to + digital tokens. +

+
+ + {/* 6. Technical Risks */} + +

+ Smart contracts may contain bugs, vulnerabilities, or + exploits. The underlying blockchain network may experience + congestion, outages, or other disruptions that affect + protocol availability. +

+

+ Protocol interactions may be unavailable or delayed due to + network conditions, smart contract state, or infrastructure + issues. No guarantees exist regarding platform operation, + uptime, or continuity. You may lose your entire participation + amount without recourse due to technical failures. +

+
+ + {/* 7. Regulatory and Jurisdictional Restrictions */} + +

+ Access is restricted by jurisdiction. +

+

+ Protocol access is not available in all jurisdictions. Users + are responsible for ensuring compliance with all applicable + local laws and regulations. The legal status of digital tokens + may change in your jurisdiction, potentially restricting or + prohibiting access without notice. +

+

+ The protocol operator reserves the right to restrict, suspend, + or terminate access for any user or jurisdiction at any time + and for any reason. +

+
+ + {/* 8. Legal Disclaimer */} + +

+ This interface is informational only and does not constitute + financial advice, an offer to sell, or a solicitation to + purchase any security or financial instrument. Nothing on this + interface should be construed as a recommendation to acquire, + sell, or hold any token. +

+

+ All information is provided “as is” without + warranties of any kind. You should consult with qualified + professional advisors before making any decisions related to + digital tokens. +

+

+ By using this protocol, you acknowledge that you have read, + understood, and accepted all restrictions, risks, and + disclaimers described on this page. No content on this + interface creates any contractual obligation or liability. +

+
-
+
-
+
); -}; - -export default Information; +} diff --git a/ts/apps/web/app/layout.tsx b/ts/apps/web/app/layout.tsx index ae4450c..3024a56 100644 --- a/ts/apps/web/app/layout.tsx +++ b/ts/apps/web/app/layout.tsx @@ -1,11 +1,18 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Geist_Mono } from "next/font/google"; +import localFont from "next/font/local"; import "../index.css"; import { Providers } from "./providers"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], +const generalSans = localFont({ + src: [ + { path: "../fonts/GeneralSans-Regular.woff2", weight: "400" }, + { path: "../fonts/GeneralSans-Medium.woff2", weight: "500" }, + { path: "../fonts/GeneralSans-Semibold.woff2", weight: "600" }, + { path: "../fonts/GeneralSans-Bold.woff2", weight: "700" }, + ], + variable: "--font-general-sans", + display: "swap", }); const geistMono = Geist_Mono({ @@ -27,9 +34,9 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {children} diff --git a/ts/apps/web/app/not-found.tsx b/ts/apps/web/app/not-found.tsx index b6c7ad0..7883261 100644 --- a/ts/apps/web/app/not-found.tsx +++ b/ts/apps/web/app/not-found.tsx @@ -1,22 +1,28 @@ -import Link from 'next/link' +import Link from "next/link"; +import { Disclaimer } from "@/components/design/Disclaimer"; export default function NotFound() { return ( -
-
-

404

-

Page Not Found

-

- The page you're looking for doesn't exist or has been moved. -

- - Go Home - +
+ +
+
+ + 404 + +

Page not found

+

+ The page you're looking for doesn't exist or has been + moved. +

+ + Go home + +
- ) + ); } - diff --git a/ts/apps/web/app/page.tsx b/ts/apps/web/app/page.tsx index 71a171b..e79df64 100644 --- a/ts/apps/web/app/page.tsx +++ b/ts/apps/web/app/page.tsx @@ -1,608 +1,318 @@ "use client"; -import { useState, useEffect } from "react"; +import { useMemo, useState } from "react"; +import Link from "next/link"; import { Layout } from "@/components/layout/Layout"; -import { BuyPrimaryInterface } from "@/components/BuyPrimaryInterface"; -import { WithdrawInterface } from "@/components/WithdrawInterface"; +import { PageContainer } from "@/components/design/PageContainer"; +import { + SectionCard, + MetricGrid, + SegmentedTabs, + type Metric, +} from "@/components/design/primitives"; +import { + PriceHistoryChart, + ChartLoading, + ChartError, + toChartPoints, +} from "@/components/design/PriceHistoryChart"; import { DisclaimerBanner } from "@/components/ui/DisclaimerBanner"; -import { PriceChart } from "@/components/PriceChart"; import { usePoolStats } from "@/hooks/usePoolStats"; -import { InfoTooltip } from "@/components/ui/InfoTooltip"; +import { usePriceHistory } from "@/hooks/usePriceHistory"; +import { useOptionalWallet } from "@/hooks/useOptionalWallet"; import { GLOSSARY } from "@/lib/glossary"; -function fmt(value: string | null) { - if (value == null) return null; - const n = parseFloat(value); - return isNaN(n) ? null : n.toFixed(4); -} - -function fmtNumber(value: number | null | undefined, digits = 4) { - if (value == null || Number.isNaN(value)) return null; - return value.toFixed(digits); +type Period = "24H" | "7D" | "30D" | "90D"; +const PERIOD_DAYS: Record = { + "24H": 1, + "7D": 7, + "30D": 30, + "90D": 90, +}; +const PERIODS = (Object.keys(PERIOD_DAYS) as Period[]).map((p) => ({ + value: p, + label: p, +})); + +function ChangeChip({ change }: { change: number }) { + const up = change >= 0; + return ( + + {up ? "↑" : "↓"} {up ? "+" : "−"} + {Math.abs(change).toFixed(2)}% · 24H + + ); } -type ModalMode = null | "buy" | "sell"; - export default function Home() { - const { stats, loading } = usePoolStats(); - const [modal, setModal] = useState(null); - const price = loading ? null : fmtNumber(stats.pricePerToken); - const nav = loading ? null : fmt(stats.navUsdc); - const liquid = loading ? null : fmt(stats.treasuryUsdc); - const pending = loading ? null : fmt(stats.pendingClaimsUsdc); - - // Close modal on ESC - useEffect(() => { - if (!modal) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setModal(null); - }; - window.addEventListener("keydown", onKey); - document.body.style.overflow = "hidden"; - return () => { - window.removeEventListener("keydown", onKey); - document.body.style.overflow = ""; - }; - }, [modal]); + const { stats, refresh } = usePoolStats(); + const wallet = useOptionalWallet(); + const connected = !!wallet?.connected; + + const [period, setPeriod] = useState("7D"); + const { + data: history, + loading: chartLoading, + refresh: refreshHistory, + } = usePriceHistory(PERIOD_DAYS[period]); + const { data: dayHistory } = usePriceHistory(1); + + const points = useMemo(() => toChartPoints(history), [history]); + const change24h = useMemo(() => { + const vals = toChartPoints(dayHistory).map((p) => p.v); + if (vals.length < 2) return null; + return (vals[vals.length - 1] / vals[0] - 1) * 100; + }, [dayHistory]); + + const rate = stats.pricePerToken; + const rateFmt = rate != null ? rate.toFixed(4) : "—"; + const updatedFmt = stats.lastRefreshed + ? stats.lastRefreshed.toLocaleTimeString("en-GB") + : "—"; + + const liquidityRatio = + stats.treasuryUsdcRaw != null && + stats.navUsdcRaw != null && + stats.navUsdcRaw > 0 + ? ((stats.treasuryUsdcRaw / stats.navUsdcRaw) * 100).toFixed(1) + : null; + + const metrics: Metric[] = [ + { + label: "Pool NAV", + value: stats.navUsdc != null ? `$${stats.navUsdc}` : "—", + tip: GLOSSARY.poolNav, + }, + { + label: "Liquid USDC", + value: stats.treasuryUsdc != null ? `$${stats.treasuryUsdc}` : "—", + tip: GLOSSARY.liquidUsdc, + }, + { + label: "Pending claims", + value: + stats.pendingClaimsUsdc != null ? `$${stats.pendingClaimsUsdc}` : "—", + tip: GLOSSARY.pendingClaims, + }, + { + label: "Circulating supply", + value: stats.circulatingSupply ?? "—", + unit: "BNKR", + tip: GLOSSARY.circulatingSupply, + }, + { + label: "Total supply", + value: stats.totalSupply ?? "—", + unit: "BNKR", + tip: GLOSSARY.totalSupply, + }, + { + label: "Liquidity ratio", + value: liquidityRatio ?? "—", + unit: "%", + tip: "Share of pool NAV held as liquid USDC in the payout vault.", + }, + ]; + + const settlesImmediately = + stats.treasuryUsdcRaw != null && + stats.pendingClaimsUsdcRaw != null && + stats.treasuryUsdcRaw >= stats.pendingClaimsUsdcRaw; return ( -
- {/* Background layers */} -
-
-
-
- -
- - - {/* Price display */} -
-
- - BUNKER CASH · LIVE FROM POOL + + {/* Overview header */} +
+
+
+

+ BNKR reference price +

+ + Calculated from pool NAV divided by circulating supply · read + on-chain +
- -
- {price != null ? ( - <> - $ - {price} - - ) : ( - - ———— - - )} +
+ + {rateFmt} + + USDC + {change24h != null && }
- -
USDC per token
- - - {/* Action buttons */} -
- - - + Pool details → +
+
-
-
-
- - Pool NAV{" "} - + {/* Price history */} + +
+
+ Price history + +
+ + 1 BNKR = {rateFmt}{" "} + USDC · {updatedFmt} + +
+ + {chartLoading ? ( + + ) : points.length < 2 ? ( + { + void refreshHistory(); + void refresh(); + }} + /> + ) : ( + + )} +
+ + {/* Protocol snapshot */} + +
+ Protocol snapshot + + Full pool status → + +
+ +
+ + {/* Action panels */} +
+
+
+ Buy BNKR + + USDC converts at the live reference rate. BNKR is minted + directly to your wallet — no order book, no counterparty. + +
+
+
+ Reference rate + + 1 BNKR = {rateFmt} USDC - {nav != null ? `$${nav}` : "—"}
-
- - Liquid USDC{" "} - - - - {liquid != null ? `$${liquid}` : "—"} +
+ 100 USDC receives + + {rate != null && rate > 0 + ? `≈ ${(100 / rate).toLocaleString("en-US", { maximumFractionDigits: 2 })} BNKR` + : "—"}
-
- - Pending Claims{" "} - - - - {pending != null ? `$${pending}` : "—"} +
+ Est. network fee + + 0.000005 SOL
-
- - {/* Price chart */} - - -
+ + {connected ? "Buy BNKR" : "Connect wallet to buy"} + +
- {/* Modal */} - {modal && ( -
setModal(null)} +
-
e.stopPropagation()} - role="dialog" - aria-modal="true" - > -
-
- - {modal === "buy" ? "Buy BunkerCash" : "Sell BunkerCash"} -
- +
+ Sell BNKR + + Sell requests settle in USDC from pool liquidity. If liquidity + is insufficient, the request enters escrow and can be cancelled + at any time. + +
+
+
+ Liquid USDC available + + {stats.treasuryUsdc != null ? `$${stats.treasuryUsdc}` : "—"} +
- -
- {modal === "buy" ? : } +
+ Pending settlement queue + + {stats.pendingClaimsUsdc != null + ? `$${stats.pendingClaimsUsdc}` + : "—"} + +
+
+ Typical settlement + + {settlesImmediately + ? "Immediate at current liquidity" + : "Queued until liquidity is replenished"} +
-
- )} -
- - + {/* Risk note */} +

+ Digital tokens involve risk and may lose all value. BNKR is a + community-based token and does not represent a deposit, equity + interest, or claim of any kind. Settlement of sell requests depends + on available pool liquidity.{" "} + Read risks and limitations. +

+ + + ); } diff --git a/ts/apps/web/app/pool/page.tsx b/ts/apps/web/app/pool/page.tsx index 2458ea7..b4e513f 100644 --- a/ts/apps/web/app/pool/page.tsx +++ b/ts/apps/web/app/pool/page.tsx @@ -2,317 +2,261 @@ import { useMemo } from "react"; import { Layout } from "@/components/layout/Layout"; -import { StatCard } from "@/components/ui/StatCard"; -import { InfoTooltip } from "@/components/ui/InfoTooltip"; -import { Info, RefreshCw } from "lucide-react"; -import { usePoolStats } from "@/hooks/usePoolStats"; -import { GLOSSARY } from "@/lib/glossary"; +import { PageContainer } from "@/components/design/PageContainer"; import { - PieChart, - Pie, - Cell, - BarChart, - Bar, - XAxis, - YAxis, - Tooltip, - ResponsiveContainer, - Legend, -} from "recharts"; + SectionCard, + CardHeader, + MetricGrid, + Shimmer, + type Metric, +} from "@/components/design/primitives"; +import { RefreshIcon, WarnIcon, Spinner } from "@/components/design/icons"; +import { usePoolStats } from "@/hooks/usePoolStats"; + +function fmtNum(n: number, maxDec = 2): string { + return n.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: maxDec, + }); +} -const CHART_COLORS = { - treasury: "hsl(166, 100%, 50%)", - pending: "hsl(220, 15%, 35%)", - nav: "hsl(45, 100%, 55%)", -}; +function fmtCompact(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(2)}K`; + return fmtNum(n); +} -const PoolStatus = () => { - const { stats, loading, error, refresh } = usePoolStats(); +function pct(part: number, total: number): number { + return total > 0 ? (part / total) * 100 : 0; +} - const formatTime = (d: Date | null) => { - if (!d) return "—"; - return d.toLocaleTimeString(); - }; +// --------------------------------------------------------------------------- +// Horizontal bar visual (used for composition breakdowns) +// --------------------------------------------------------------------------- +function CompositionBar({ + segments, +}: { + segments: { label: string; value: number; color: string }[]; +}) { + const total = segments.reduce((s, seg) => s + seg.value, 0); + if (total <= 0) return null; + + return ( +
+
+ {segments.map((seg) => { + const w = pct(seg.value, total); + if (w <= 0) return null; + return ( +
+ ); + })} +
+
+ {segments.map((seg) => ( + + + {seg.label} + + ${fmtCompact(seg.value)} + + + ({pct(seg.value, total).toFixed(1)}%) + + + ))} +
+
+ ); +} - const supplyPieData = useMemo(() => { +// --------------------------------------------------------------------------- +// Pool page +// --------------------------------------------------------------------------- +export default function PoolPage() { + const { stats, loading, refreshing, error, refresh } = usePoolStats(); + + const shimmer = ; + + const summaryMetrics: Metric[] = useMemo( + () => [ + { + label: "Reference rate", + value: loading ? shimmer : stats.pricePerToken != null ? `$${stats.pricePerToken.toFixed(4)}` : "—", + unit: stats.pricePerToken != null ? "USDC" : undefined, + tip: "Current per-token price derived from NAV / circulating supply.", + }, + { + label: "Reference value (NAV)", + value: loading ? shimmer : stats.navUsdcRaw != null ? `$${fmtCompact(stats.navUsdcRaw)}` : "—", + tip: "Total USDC value tracked on-chain backing all circulating BNKR.", + }, + { + label: "Treasury USDC", + value: loading ? shimmer : stats.treasuryUsdcRaw != null ? `$${fmtCompact(stats.treasuryUsdcRaw)}` : "—", + tip: "Liquid USDC held in the protocol treasury account.", + valueClassName: "text-mint", + }, + { + label: "Pending claims", + value: loading ? shimmer : stats.pendingClaimsUsdcRaw != null ? `$${fmtCompact(stats.pendingClaimsUsdcRaw)}` : "—", + tip: "Total USDC requested by open sell requests awaiting settlement.", + valueClassName: "text-warn", + }, + ], + [loading, stats], + ); + + const supplyMetrics: Metric[] = useMemo( + () => [ + { + label: "Total supply", + value: loading ? shimmer : stats.totalSupply ?? "—", + unit: "BNKR", + tip: "All BNKR tokens that exist — circulating plus escrowed for pending sells.", + }, + { + label: "Circulating supply", + value: loading ? shimmer : stats.circulatingSupply ?? "—", + unit: "BNKR", + tip: "BNKR freely held by users. Escrow tokens back pending sell requests and are excluded until settled.", + }, + { + label: "Escrowed BNKR", + value: loading + ? shimmer + : stats.totalSupplyRaw != null && stats.circulatingSupplyRaw != null + ? fmtCompact(stats.totalSupplyRaw - stats.circulatingSupplyRaw) + : "—", + unit: "BNKR", + tip: "BNKR locked in escrow for open sell requests — returned if cancelled, burned at settlement.", + }, + ], + [loading, stats], + ); + + const treasurySegments = useMemo(() => { if (stats.treasuryUsdcRaw == null || stats.pendingClaimsUsdcRaw == null) return null; return [ - { name: "Treasury USDC", value: stats.treasuryUsdcRaw }, - { name: "Open Requests", value: stats.pendingClaimsUsdcRaw }, + { label: "Available liquidity", value: Math.max(0, stats.treasuryUsdcRaw - stats.pendingClaimsUsdcRaw), color: "var(--mint)" }, + { label: "Reserved for claims", value: Math.min(stats.pendingClaimsUsdcRaw, stats.treasuryUsdcRaw), color: "var(--amber)" }, ]; }, [stats.treasuryUsdcRaw, stats.pendingClaimsUsdcRaw]); - const barData = useMemo(() => { - if ( - stats.navUsdcRaw == null || - stats.treasuryUsdcRaw == null || - stats.pendingClaimsUsdcRaw == null - ) + const supplySegments = useMemo(() => { + if (stats.totalSupplyRaw == null || stats.circulatingSupplyRaw == null) return null; + const escrow = stats.totalSupplyRaw - stats.circulatingSupplyRaw; return [ - { name: "Reference Value", value: stats.navUsdcRaw }, - { name: "Treasury", value: stats.treasuryUsdcRaw }, - { name: "Open Requests", value: stats.pendingClaimsUsdcRaw }, + { label: "Circulating", value: stats.circulatingSupplyRaw, color: "var(--mint)" }, + { label: "Escrowed", value: escrow, color: "var(--amber)" }, ]; - }, [stats.navUsdcRaw, stats.treasuryUsdcRaw, stats.pendingClaimsUsdcRaw]); - - const formatNum = (v: number) => - v.toLocaleString(undefined, { maximumFractionDigits: 2 }); - - const CustomTooltipContent = ({ - active, - payload, - }: { - active?: boolean; - payload?: Array<{ name: string; value: number; payload: { name: string } }>; - }) => { - if (!active || !payload?.length) return null; - const d = payload[0]; - return ( -
-

{d.payload.name}

-

{formatNum(d.value)} USDC

-
- ); - }; + }, [stats.totalSupplyRaw, stats.circulatingSupplyRaw]); - const renderLoading = ( - - Loading... - - ); + const liquidityHealth = useMemo(() => { + if (stats.treasuryUsdcRaw == null || stats.pendingClaimsUsdcRaw == null) + return null; + if (stats.pendingClaimsUsdcRaw <= 0) return { ratio: Infinity, label: "Healthy", tone: "text-mint" as const }; + const ratio = stats.treasuryUsdcRaw / stats.pendingClaimsUsdcRaw; + if (ratio >= 2) return { ratio, label: "Healthy", tone: "text-mint" as const }; + if (ratio >= 1) return { ratio, label: "Adequate", tone: "text-info" as const }; + return { ratio, label: "Low", tone: "text-warn" as const }; + }, [stats.treasuryUsdcRaw, stats.pendingClaimsUsdcRaw]); return ( -
-
- {/* Header */} -
-

- Pool Status -

-

- Read-only protocol transparency for supply, treasury, and open - request metrics. -

+ + {/* Page heading */} +
+
+

Pool

+ + Read-only protocol transparency — supply, treasury, and settlement + metrics sourced directly from on-chain state. +
- - {/* Error Banner */} - {error && ( -
- {error} -
- )} - - {/* Top row: Reference Rate + Treasury */} -
- - ${stats.pricePerToken?.toFixed(4) ?? "—"} USDC - - ) - } - note="Interface reference metric" - className="glow-primary h-full" - /> - - ${stats.treasuryUsdc ?? "0"} USDC - - ) - } - note="Protocol treasury balance" - className="glass-card h-full" - /> +
+ {stats.lastRefreshed && ( + + {stats.lastRefreshed.toLocaleTimeString("en-GB")} + + )} +
+
- {/* Supply stats row */} -
- - Total Supply - - - } - value={ - loading - ? renderLoading - : ( - - {stats.totalSupply ?? "—"} BNKR - - ) - } - note="All tokens that exist, incl. escrowed for pending sells" - className="glass-card h-full" - /> - - ${stats.pendingClaimsUsdc ?? "—"} USDC - - ) - } - note="Pending settlement requests" - className="glass-card h-full" - /> - - Circulating Supply - - - } - value={ - loading - ? renderLoading - : ( - - {stats.circulatingSupply ?? "—"} BunkerCash - - ) - } - note="Excludes tokens escrowed for pending sell requests" - className="glow-primary h-full" - /> + {/* Stale / error banner */} + {error && ( +
+ + {error}
+ )} + + {/* Pool summary metrics */} + + + Liquidity: {liquidityHealth.label} + {liquidityHealth.ratio !== Infinity && + ` (${liquidityHealth.ratio.toFixed(1)}×)`} + + ) + } + /> + + - {/* Charts row */} - {!loading && supplyPieData && barData && ( -
- {/* Donut: Treasury vs Open Requests */} -
-

Treasury Breakdown

- - - - - - - } /> - ( - - {value} - - )} - /> - - -
+ {/* Treasury composition */} + {treasurySegments && !loading && ( + + + + + )} - {/* Bar: reference value vs treasury vs open requests */} -
-

Protocol Liquidity Metrics

- - - - - v >= 1_000_000 - ? `${(v / 1_000_000).toFixed(1)}M` - : v >= 1_000 - ? `${(v / 1_000).toFixed(1)}K` - : v.toString() - } - width={52} - /> - } cursor={false} /> - - {barData.map((entry, idx) => ( - - ))} - - - -
-
+ {/* Supply composition */} + + + + {supplySegments && !loading && ( + )} + - {/* Info + Refresh */} -
-
-
- -

- All values are read from on-chain data and related protocol - state. Displayed metrics are informational only and do not - represent guarantees of liquidity, settlement, or value. -

-
-
-
- - Last refreshed: {formatTime(stats.lastRefreshed)} - - -
-
+ {/* Protocol data source */} +
+ + + All values are read from on-chain data and related protocol state. + Displayed metrics are informational only and do not represent + guarantees of liquidity, settlement, or value. +
-
+ ); -}; - -export default PoolStatus; +} diff --git a/ts/apps/web/app/providers.tsx b/ts/apps/web/app/providers.tsx index 06ba5c8..9779589 100644 --- a/ts/apps/web/app/providers.tsx +++ b/ts/apps/web/app/providers.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { Provider as JotaiProvider } from "jotai"; +import { ThemeProvider } from "next-themes"; import { SolanaProvider } from "@/providers/SolanaProvider"; import { ToastProvider } from "@/components/ui/ToastContext"; import { getConfiguredSolanaCluster } from "@/lib/solana-env"; @@ -12,26 +13,33 @@ function getWalletEnv(): "mainnet-beta" | "devnet" | "testnet" | "localnet" { export function Providers({ children }: { children: ReactNode }) { return ( - - - {children} - - + + + {children} + + + ); } diff --git a/ts/apps/web/app/sell/page.tsx b/ts/apps/web/app/sell/page.tsx index 9a9549a..11b0832 100644 --- a/ts/apps/web/app/sell/page.tsx +++ b/ts/apps/web/app/sell/page.tsx @@ -1,14 +1,5 @@ -"use client"; - import { TradePageContent } from "@/components/TradePageContent"; export default function SellPage() { - return ( - - ); + return ; } diff --git a/ts/apps/web/app/support/page.tsx b/ts/apps/web/app/support/page.tsx index dbe1404..fd94842 100644 --- a/ts/apps/web/app/support/page.tsx +++ b/ts/apps/web/app/support/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; -import { Mail, Phone, ShieldCheck, ArrowLeftRight } from "lucide-react"; import { Layout } from "@/components/layout/Layout"; +import { PageContainer } from "@/components/design/PageContainer"; +import { SectionCard, CardHeader } from "@/components/design/primitives"; import { SupportRequestForm } from "@/components/SupportRequestForm"; import { getSupportContactDetails } from "@/lib/support-requests"; @@ -8,10 +9,6 @@ export const metadata = { title: "Support | BunkerCash", }; -function buildTelHref(phone: string) { - return `tel:${phone.replace(/[^+\d]/g, "")}`; -} - interface SupportPageProps { searchParams?: Promise<{ source?: string; @@ -30,116 +27,94 @@ export default async function SupportPage({ searchParams }: SupportPageProps) { return ( -
-
-
-
-
- Support Request -
-

- Contact the BunkerCash support team -

-

- Use this page if access was blocked in error or if you need help - with protocol eligibility, account review, or operational support. -

+ +
+

Support

+ + Get help with access, eligibility, or operational issues. + +
-
-
-
- -
-

+
+ {/* Left: info cards */} +
+ +
+
+ Eligibility review -

-

+ + Tell us why the restriction looks incorrect and include any relevant jurisdiction details. -

+
-
-
- -
-

+
+
+ Follow-up channel -

-

+ + Leave an email and optional phone number so the team can respond without a wallet connection. -

+
-
+ -
-

- Direct contact -

-
+ + + - -

- Prefer a written record? Submit the form and the request will be - logged for the admin team. -

- - - Back to restricted-access notice - -
+
+ + ← Back to restricted-access notice + +
+
-
-
-

- Submit a support request -

-

- Requests submitted here are stored for review in the admin panel. + {/* Right: form */} + + +

+

+ Requests submitted here are stored for review by the admin team.

+
- -
+
-
+ ); } diff --git a/ts/apps/web/app/wallet/WalletPageClient.tsx b/ts/apps/web/app/wallet/WalletPageClient.tsx index e1c9a47..9acbd15 100644 --- a/ts/apps/web/app/wallet/WalletPageClient.tsx +++ b/ts/apps/web/app/wallet/WalletPageClient.tsx @@ -1,401 +1,788 @@ "use client"; import { useMemo, useState } from "react"; +import { useConnection } from "@solana/wallet-adapter-react"; +import { useSetAtom } from "jotai"; import { Layout } from "@/components/layout/Layout"; -import { PoolTransactions } from "@/components/PoolTransactions"; -import { StatCard } from "@/components/ui/StatCard"; -import { Badge } from "@/components/ui/badge"; -import { AlertCircle, CheckCircle, Clock, Wallet } from "lucide-react"; +import { PageContainer } from "@/components/design/PageContainer"; +import { + SectionCard, + CardHeader, + MetricGrid, + StatusPill, + SegmentedTabs, + Shimmer, + type Metric, + type PillTone, +} from "@/components/design/primitives"; +import { + CopyIcon, + ExternalIcon, + RefreshIcon, + WalletIcon, + Spinner, +} from "@/components/design/icons"; import { type Claim, useMyClaims } from "@/hooks/useMyClaims"; import { useOptionalWallet } from "@/hooks/useOptionalWallet"; import { usePoolStats } from "@/hooks/usePoolStats"; import { useTokenBalance } from "@/hooks/useTokenBalance"; +import { useUsdcBalance } from "@/hooks/useUsdcBalance"; import { useMyTransactions } from "@/hooks/useMyTransactions"; +import { useCancelClaim, isClaimCancellable } from "@/hooks/useCancelClaim"; +import { connectModalOpenAtom } from "@/lib/ui-atoms"; +import { explorerTxUrl, explorerAddressUrl } from "@/lib/explorer"; +import { getClusterFromEndpoint } from "@/lib/constants"; +import type { Transaction, SellStatus } from "@/types"; const USDC_DECIMALS = 6; -function formatAmount(value: number, options?: Intl.NumberFormatOptions) { - return value.toLocaleString("en-US", { - maximumFractionDigits: 2, - ...options, +function fmtNum(n: number, maxDec = 2): string { + return n.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: maxDec, }); } -function formatUsdcAmount(raw: bigint) { +function fmtUsdcRaw(raw: string): number { return Number(raw) / 10 ** USDC_DECIMALS; } -function getClaimProgress(claim: Claim) { - const requestedRaw = BigInt(claim.requestedUsdc); - const paidRaw = BigInt(claim.paidUsdc); - const cappedPaidRaw = paidRaw > requestedRaw ? requestedRaw : paidRaw; - - if (requestedRaw <= BigInt(0)) { - return { - requestedRaw, - paidRaw: cappedPaidRaw, - progressPct: paidRaw > BigInt(0) ? 100 : 0, - }; - } +function shortenSig(sig: string): string { + return `${sig.slice(0, 4)}…${sig.slice(-4)}`; +} - const progressPct = - Number((cappedPaidRaw * BigInt(10000)) / requestedRaw) / 100; - return { requestedRaw, paidRaw: cappedPaidRaw, progressPct }; +function shortenAddr(addr: string): string { + return `${addr.slice(0, 4)}…${addr.slice(-4)}`; } -export default function WalletPageClient() { - const [activeTab, setActiveTab] = useState<"transactions" | "settlements">( - "transactions", - ); - const wallet = useOptionalWallet(); - const connected = wallet?.connected ?? false; - const { balance, loading: isLoadingBalance, error: balanceError } = useTokenBalance(); - const { stats, loading: isLoadingStats, error: statsError } = usePoolStats(); - const { transactions } = useMyTransactions(); - const { claims, loading: isLoadingClaims, error: claimsError } = useMyClaims(); - - const tokenBalance = useMemo(() => Number(balance || "0"), [balance]); - const pricePerToken = stats.pricePerToken; - const estimatedAssetValue = - pricePerToken != null && Number.isFinite(tokenBalance) - ? tokenBalance * pricePerToken - : null; - - const boughtUsdcTotal = useMemo( - () => - transactions - .filter((tx) => tx.type === "investment") - .reduce((sum, tx) => sum + tx.amount, 0), - [transactions], +function copyText(text: string) { + void navigator.clipboard.writeText(text); +} + +// --------------------------------------------------------------------------- +// Disconnected state +// --------------------------------------------------------------------------- +function DisconnectedState() { + const openConnect = useSetAtom(connectModalOpenAtom); + return ( +
+ + + +
+ No wallet connected + + Connect your Solana wallet to view balances, transaction history, and + settlement activity. + +
+ +
); +} - const soldUsdcTotal = useMemo( - () => - transactions - .filter((tx) => tx.type === "withdrawal") - .reduce((sum, tx) => sum + tx.amount, 0), - [transactions], +// --------------------------------------------------------------------------- +// Wallet summary card +// --------------------------------------------------------------------------- +function WalletSummary({ + address, + cluster, +}: { + address: string; + cluster: string; +}) { + const [copied, setCopied] = useState(false); + const copy = () => { + copyText(address); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( + ); +} + +// --------------------------------------------------------------------------- +// Balance metrics +// --------------------------------------------------------------------------- +function BalanceMetrics() { + const { balance: bnkrBalance, loading: bnkrLoading } = useTokenBalance(); + const { balance: usdcBalance } = useUsdcBalance(); + const { stats, loading: statsLoading } = usePoolStats(); + const { claims } = useMyClaims(); - const openRequests = useMemo( - () => claims.filter((claim) => !claim.processed).length, - [claims], + const bnkr = Number(bnkrBalance || "0"); + const usdc = Number(usdcBalance || "0"); + const price = stats.pricePerToken; + + const openClaims = claims.filter( + (c) => !c.cancelled && !c.processed && c.bunkercashRemaining !== "0", + ); + const escrowBnkr = openClaims.reduce( + (sum, c) => sum + Number(c.bunkercashRemaining) / 1e6, + 0, ); - const totalSettledUsdc = useMemo( - () => - claims.reduce( - (sum, claim) => sum + Number(claim.paidUsdc) / 10 ** USDC_DECIMALS, - 0, - ), - [claims], + const pendingUsdc = openClaims.reduce( + (sum, c) => sum + fmtUsdcRaw(c.requestedUsdc) - fmtUsdcRaw(c.paidUsdc), + 0, ); - const totalRequestedUsdc = useMemo( - () => - claims.reduce( - (sum, claim) => sum + Number(claim.requestedUsdc) / 10 ** USDC_DECIMALS, - 0, + const totalExposure = + price != null ? bnkr * price + escrowBnkr * price + usdc : null; + + const loading = bnkrLoading || statsLoading; + + const metrics: Metric[] = [ + { + label: "USDC balance", + value: loading ? : fmtNum(usdc), + unit: "USDC", + tip: "Available USDC in your connected wallet.", + }, + { + label: "BNKR balance", + value: loading ? : fmtNum(bnkr, 4), + unit: "BNKR", + tip: "BunkerCash tokens held in your wallet.", + }, + { + label: "In escrow", + value: loading ? : fmtNum(escrowBnkr, 4), + unit: "BNKR", + tip: "BNKR locked in open sell requests — returned if you cancel.", + }, + { + label: "Pending USDC", + value: loading ? ( + + ) : ( + fmtNum(Math.max(0, pendingUsdc)) + ), + unit: "USDC", + tip: "USDC you've requested but hasn't settled yet.", + }, + { + label: "Total exposure", + value: loading ? ( + + ) : totalExposure != null ? ( + `$${fmtNum(totalExposure)}` + ) : ( + "—" ), - [claims], + tip: "Estimated total value of all positions at current reference rate.", + }, + { + label: "Open requests", + value: loading ? ( + + ) : ( + openClaims.length.toString() + ), + tip: "Active sell requests awaiting settlement.", + }, + ]; + + return ( + + + + ); +} - const totalRequests = claims.length; - - if (!connected) { - return ( - -
-
-
-

Wallet

-

- Connect your wallet to view your assets, transactions, and settlement activity. -

-
-
- Complete access check and connect wallet to open your wallet overview. -
-
-
-
- ); +// --------------------------------------------------------------------------- +// Transaction row + table +// --------------------------------------------------------------------------- +type TxFilter = "all" | "buys" | "sells"; + +const TX_FILTER_ITEMS: { value: TxFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "buys", label: "Buys" }, + { value: "sells", label: "Sells" }, +]; + +function statusTone(status?: SellStatus): PillTone { + switch (status) { + case "pending": + return "warn"; + case "partial": + return "info"; + case "settled": + return "mint"; + case "cancelled": + return "neutral"; + default: + return "mint"; } +} + +function statusLabel(tx: Transaction): string { + if (tx.type === "investment") return "Completed"; + switch (tx.status) { + case "pending": + return "Pending"; + case "partial": + return "Partial"; + case "settled": + return "Settled"; + case "cancelled": + return "Cancelled"; + default: + return "Completed"; + } +} + +function TxDesktopRow({ + tx, + cluster, +}: { + tx: Transaction; + cluster: string; +}) { + const isBuy = tx.type === "investment"; + const amount = isBuy + ? `+$${fmtNum(tx.amount)}` + : tx.status === "settled" || tx.status === "partial" + ? `-$${fmtNum(tx.settledUsdc ?? tx.amount)}` + : tx.status === "cancelled" + ? "—" + : `$${fmtNum(tx.requestedUsdc ?? tx.amount)}`; return ( - -
-
-
-
- -
-

Wallet

-

- Your balance, buy and sell transactions, and settlement progress in one place. -

-
+ + + + + + {isBuy ? "Buy" : "Sell"} + + + + + + {amount} + + + + {tx.tokenAmount != null && tx.tokenAmount > 0 && ( + + {tx.tokenAmount.toLocaleString("en-US", { maximumFractionDigits: 4 })}{" "} + BNKR + + )} + + + + {statusLabel(tx)} + + + + {tx.timestamp.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} + ,{" "} + {tx.timestamp.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + })} + + + + {tx.txSignature && ( + <> + + [0], tx.txSignature)} + target="_blank" + rel="noopener noreferrer" + title="View on Explorer" + className="text-ink-3 no-underline transition-colors hover:text-mint" + > + + + + )} + + + + ); +} -
- - Network Error - ) : ( - "Current wallet balance" - ) - } - /> - +
+ + + + {isBuy ? "Buy" : "Sell"} + + + {statusLabel(tx)} + + + + {amount} + +
+
+ + {tx.timestamp.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} + ,{" "} + {tx.timestamp.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + })} + {tx.tokenAmount != null && tx.tokenAmount > 0 && ( + <> + {" · "} + + {tx.tokenAmount.toLocaleString("en-US", { + maximumFractionDigits: 4, + })} + {" "} + BNKR + + )} + + {tx.txSignature && ( + + + [0], tx.txSignature)} + target="_blank" + rel="noopener noreferrer" + title="View on Explorer" + className="text-ink-3 no-underline transition-colors hover:text-mint" + > + + + + )} +
+
+ ); +} + +function TransactionsSection({ cluster }: { cluster: string }) { + const { transactions, loading, error, refresh } = useMyTransactions(); + const [filter, setFilter] = useState("all"); + const [showCount, setShowCount] = useState(20); + + const filtered = useMemo(() => { + if (filter === "buys") + return transactions.filter((t) => t.type === "investment"); + if (filter === "sells") + return transactions.filter((t) => t.type === "withdrawal"); + return transactions; + }, [transactions, filter]); + + const visible = filtered.slice(0, showCount); + const hasMore = filtered.length > showCount; + + return ( + + + +
+ } + /> -
- - ${formatAmount(totalRequestedUsdc)} - - } - note="Total submitted sell requests" - /> - - + {loading && transactions.length === 0 ? ( +
+ + Loading transactions… +
+ ) : error ? ( +
+ {error} + +
+ ) : visible.length === 0 ? ( +
+ + {filter === "all" + ? "No transactions yet." + : `No ${filter} found.`} + + + {filter === "all" + ? "Transactions will appear here after your first trade." + : 'Try switching to "All" to see all activity.'} + +
+ ) : ( + <> + {/* Desktop table */} +
+ + + + + + + + + + + + {visible.map((tx) => ( + + ))} + +
TypeAmountTokensStatusDate +
-
-
-
-

Wallet Activity

-

- Switch between buy/sell transactions and settlement progress. -

-
-
- - -
+ {/* Mobile cards */} +
+ {visible.map((tx) => ( + + ))} +
+ + {hasMore && ( +
+
+ )} + + )} + + ); +} + +// --------------------------------------------------------------------------- +// Settlements section +// --------------------------------------------------------------------------- +function ClaimRow({ + claim, + cluster, + cancelling, + onCancel, +}: { + claim: Claim; + cluster: string; + cancelling: boolean; + onCancel: () => void; +}) { + const requested = fmtUsdcRaw(claim.requestedUsdc); + const paid = fmtUsdcRaw(claim.paidUsdc); + const progress = + requested > 0 ? Math.min(100, (paid / requested) * 100) : paid > 0 ? 100 : 0; + + const tone: PillTone = claim.cancelled + ? "neutral" + : claim.processed + ? "mint" + : paid > 0 + ? "info" + : "warn"; + + const label = claim.cancelled + ? "Cancelled" + : claim.processed + ? "Settled" + : paid > 0 + ? "Partial" + : "Pending"; + + const cancellable = isClaimCancellable(claim); - {activeTab === "transactions" ? ( - <> -
- - -
- -
- -
-

Transaction History

-

- All recorded buy and sell activity for your connected wallet. -

-
-
- - - ) : ( - <> -
- -
-

Settlement History

-

- Progress bars and statuses for every sell request connected to this wallet. -

-
-
- - {claimsError ? ( -
- {claimsError} -
- ) : isLoadingClaims ? ( -
- Loading sell requests... -
- ) : claims.length > 0 ? ( -
- - - - - - - - - - - - {claims.map((claim) => { - const { requestedRaw, paidRaw, progressPct } = - getClaimProgress(claim); - const isPartiallySettled = - !claim.processed && paidRaw > BigInt(0); - - return ( - - - - - - - - ); - })} - -
- ID - - Requested - - Settled - - Progress - - Status -
- #{claim.id} - - ${formatUsdcAmount(BigInt(claim.requestedUsdc)).toLocaleString()} - - ${formatUsdcAmount(BigInt(claim.paidUsdc)).toLocaleString()} - -
-
- - {requestedRaw > BigInt(0) ? ( - <> - ${formatUsdcAmount(paidRaw).toLocaleString()} / $ - {formatUsdcAmount(requestedRaw).toLocaleString()} - - ) : ( - "Settlement amount pending" - )} - - {progressPct.toFixed(2)}% -
-
-
-
-
-
- - {claim.processed ? ( - - ) : ( - - )} - {claim.cancelled - ? "Cancelled" - : claim.processed - ? "Settled" - : isPartiallySettled - ? "Partially Settled" - : "Pending"} - -
-
- ) : ( -
- -

No settlement activity found

-
- )} - - )} -
+ return ( +
+ + +
+
+
+ + {progress.toFixed(1)}% +
+ +
+ + Requested{" "} + + ${fmtNum(requested)} + + + + Settled{" "} + + ${fmtNum(paid)} + + +
+
+ ); +} + +function SettlementsSection({ cluster }: { cluster: string }) { + const { + claims, + loading: claimsLoading, + error: claimsError, + refreshClaims, + } = useMyClaims(); + const { cancelClaim, cancellingClaim } = useCancelClaim({ + onDone: () => void refreshClaims(), + }); + + return ( + + void refreshClaims()} + title="Refresh" + className="flex h-[26px] w-[26px] items-center justify-center rounded-md border border-line-2 bg-surface-2 text-ink-3 transition-colors hover:border-ink-3 hover:text-ink-2" + > + + + } + /> + + {claimsLoading && claims.length === 0 ? ( +
+ + Loading settlements… +
+ ) : claimsError ? ( +
+ {claimsError} + +
+ ) : claims.length === 0 ? ( +
+ No settlement activity + + Sell requests and their settlement progress will appear here. + +
+ ) : ( + claims.map((claim) => ( + void cancelClaim(claim)} + /> + )) + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- +type WalletTab = "transactions" | "settlements"; + +const TAB_ITEMS: { value: WalletTab; label: string }[] = [ + { value: "transactions", label: "Transactions" }, + { value: "settlements", label: "Settlements" }, +]; + +export default function WalletPageClient() { + const wallet = useOptionalWallet(); + const connected = !!wallet?.connected; + const address = wallet?.publicKey?.toBase58() ?? ""; + const { connection } = useConnection(); + const cluster = getClusterFromEndpoint(connection.rpcEndpoint ?? ""); + const [tab, setTab] = useState("transactions"); + + return ( + + +
+

Wallet

+ + Your balances, transaction history, and settlement progress in one + place. + +
+ + {!connected ? ( + + ) : ( + <> + + + +
+
+ +
+ + {tab === "transactions" ? ( + + ) : ( + + )} +
+ + )} +
); } diff --git a/ts/apps/web/components/BuyPrimaryInterface.tsx b/ts/apps/web/components/BuyPrimaryInterface.tsx index 5afc281..13d57e2 100644 --- a/ts/apps/web/components/BuyPrimaryInterface.tsx +++ b/ts/apps/web/components/BuyPrimaryInterface.tsx @@ -5,6 +5,7 @@ import type { Idl, Program } from '@coral-xyz/anchor' import { useConnection } from '@solana/wallet-adapter-react' import { PublicKey, SendTransactionError, SystemProgram, Transaction, type TransactionInstruction } from '@solana/web3.js' import { getAssociatedTokenAddressSync, createAssociatedTokenAccountIdempotentInstruction, TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { useSetAtom } from "jotai"; import { getBunkercashMintPda, getFeeConfigPda, @@ -14,21 +15,34 @@ import { fetchConfiguredUsdcMint, fetchMintTokenProgram, getProgram, + getReadonlyProgram, type ProgramWallet, PROGRAM_ID, } from "@/lib/program"; import { countFractionalDigits, parseUiAmountToBaseUnits } from "@/lib/amounts"; import { getClusterFromEndpoint } from "@/lib/constants"; -import { ArrowDown, AlertCircle } from "lucide-react"; import { BN } from '@coral-xyz/anchor' import { useToast } from "@/components/ui/ToastContext"; import { useSupportedUsdcMint } from "@/hooks/useSupportedUsdcMint"; +import { useUsdcBalance } from "@/hooks/useUsdcBalance"; +import { useTokenBalance } from "@/hooks/useTokenBalance"; import { invalidateTransactionCache } from "@/hooks/useMyTransactions"; import { sendAndConfirmWalletTransaction } from "@/lib/sendAndConfirmWalletTransaction"; import { useOptionalWallet } from "@/hooks/useOptionalWallet"; -import { PhantomConnectButton } from "@/components/wallet/PhantomConnectButton"; -import { InfoTooltip } from "@/components/ui/InfoTooltip"; -import { GLOSSARY } from "@/lib/glossary"; +import { connectModalOpenAtom } from "@/lib/ui-atoms"; +import { + AmountInputCard, + AmountOutputCard, + ComposerCta, + DetailRow, + type CtaKind, +} from "@/components/trade/composerParts"; +import { + ReviewSheet, + type SheetPhase, + type SheetRow, +} from "@/components/trade/ReviewSheet"; +import { WarnIcon } from "@/components/design/icons"; const USDC_DECIMALS = 6 const USDC_SCALE = 10n ** BigInt(USDC_DECIMALS) @@ -88,10 +102,10 @@ type BuyPoolState = { }; // Module-level cache of the last successfully fetched pool state. Survives -// component unmount/remount (Buy tab switches, Home buy-modal open/close) so -// the price shows instantly and we revalidate in the background instead of -// gating the whole UI behind "Loading pool price…". Keyed by RPC endpoint so a -// cluster switch never surfaces stale data. +// component unmount/remount (tab/page switches) so the price shows instantly +// and we revalidate in the background instead of gating the whole UI behind a +// loading state. Keyed by RPC endpoint so a cluster switch never surfaces +// stale data. let buyPoolStateCache: { endpoint: string; state: BuyPoolState } | null = null; interface BuyPrimaryMethods { @@ -123,19 +137,24 @@ export function BuyPrimaryInterface() { const signTransaction = wallet?.signTransaction const signAllTransactions = wallet?.signAllTransactions const { showToast } = useToast(); + const openConnect = useSetAtom(connectModalOpenAtom); const [usdcAmount, setUsdcAmount] = useState(""); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [txSig, setTxSig] = useState(null); - const [usdcBalance, setUsdcBalance] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + const [phase, setPhase] = useState("review"); + const [liveSig, setLiveSig] = useState(null); + const [failureMessage, setFailureMessage] = useState(null); + const [receipt, setReceipt] = useState<{ k: string; v: string }[]>([]); const txInFlight = useRef(false); const endpoint = connection.rpcEndpoint ?? ""; const [poolState, setPoolState] = useState(() => buyPoolStateCache?.endpoint === endpoint ? buyPoolStateCache.state : null, ); - const [poolRefreshing, setPoolRefreshing] = useState(false); const [poolError, setPoolError] = useState(null); + const { balance: usdcBalance } = useUsdcBalance(); + const { balance: bnkrBalance } = useTokenBalance(); + const program = useMemo( () => publicKey && signTransaction && signAllTransactions @@ -165,10 +184,11 @@ export function BuyPrimaryInterface() { const { usdcMint, usdcTokenProgram, error: usdcMintError } = useSupportedUsdcMint(); const fetchPoolState = useCallback(async () => { - if (!program || !connection) return; - if (buyPoolStateCache?.endpoint === endpoint) setPoolRefreshing(true); + if (!connection) return; try { - const accountApi = (program as Program).account as { + // Pool state is public — read it without a connected wallet too. + const readProgram = program ?? getReadonlyProgram(connection); + const accountApi = (readProgram as Program).account as { pool: { fetch: (key: PublicKey) => Promise }; purchaseLimitConfig?: { fetch: (key: PublicKey) => Promise; @@ -222,8 +242,6 @@ export function BuyPrimaryInterface() { } catch { setPoolError("not_initialized"); setPoolState(null); - } finally { - setPoolRefreshing(false); } }, [program, poolPda, connection, purchaseLimitConfigPda, feeConfigPda, endpoint]); @@ -231,49 +249,6 @@ export function BuyPrimaryInterface() { void fetchPoolState(); }, [fetchPoolState]); - useEffect(() => { - if (!publicKey || !connection || !usdcMint || !usdcTokenProgram) return; - const fetchBalance = async () => { - try { - const userUsdc = getAssociatedTokenAddressSync( - usdcMint, - publicKey, - false, - usdcTokenProgram, - ASSOCIATED_TOKEN_PROGRAM_ID, - ); - const balance = await connection.getTokenAccountBalance(userUsdc); - setUsdcBalance(balance.value.uiAmountString ?? "0"); - } catch (e: unknown) { - // If the account doesn't exist, it throws. - // We can double check if it's an account-not-found error, but for now defaulting to 0 is safe for UI. - if (e instanceof Error && e.message.includes("could not find account")) { - setUsdcBalance("0"); - } else { - console.error("Error fetching USDC balance:", e); - setUsdcBalance("0"); - } - } - }; - void fetchBalance(); - const id = connection.onAccountChange( - getAssociatedTokenAddressSync( - usdcMint, - publicKey, - false, - usdcTokenProgram, - ASSOCIATED_TOKEN_PROGRAM_ID, - ), - () => { - // For simplicity, just refetch or parse info. Here avoiding intricate parsing for speed. - void fetchBalance(); - }, - ); - return () => { - connection.removeAccountChangeListener(id); - }; - }, [publicKey, connection, usdcMint, usdcTokenProgram]); - const pricePerToken = poolState ? derivePrice(poolState.nav, poolState.totalBunkercashSupply) : null; @@ -307,11 +282,6 @@ export function BuyPrimaryInterface() { return (usdcAmountRaw * BigInt(poolState.purchaseFeeBps)) / 10_000n; }, [poolState, usdcAmountRaw]); - const netInvestmentRaw = useMemo(() => { - if (!usdcAmountRaw || purchaseFeeRaw == null) return null; - return usdcAmountRaw - purchaseFeeRaw; - }, [usdcAmountRaw, purchaseFeeRaw]); - const tokenAmountUi = tokenAmountRaw != null ? toUi(tokenAmountRaw, USDC_DECIMALS) : ""; @@ -338,24 +308,26 @@ export function BuyPrimaryInterface() { ? "Global purchase cap reached" : `Only ${toUi(remainingPurchaseCapacityRaw, USDC_DECIMALS)} USDC of purchase capacity remains`; } - if (usdcBalanceRaw != null && usdcAmountRaw > usdcBalanceRaw) { - return "Insufficient USDC balance"; + if (publicKey && usdcBalanceRaw != null && usdcAmountRaw > usdcBalanceRaw) { + return `Amount exceeds your USDC balance of ${usdcBalance ?? "0"}.`; } if (tokenAmountRaw != null && tokenAmountRaw <= 0n) { return "Amount is too small after fees and current pricing"; } return null; - }, [usdcAmount, usdcAmountRaw, usdcBalanceRaw, remainingPurchaseCapacityRaw, tokenAmountRaw]); + }, [usdcAmount, usdcAmountRaw, usdcBalanceRaw, usdcBalance, remainingPurchaseCapacityRaw, tokenAmountRaw, publicKey]); const handleBuy = async () => { if (!usdcMint) { const msg = `Unsupported network: no configured USDC mint for ${currentCluster}.`; - setError(msg); + setFailureMessage(msg); + setPhase("failed"); showToast(msg, "error"); return; } if (usdcMintError) { - setError(usdcMintError); + setFailureMessage(usdcMintError); + setPhase("failed"); showToast(usdcMintError, "error"); return; } @@ -377,15 +349,18 @@ export function BuyPrimaryInterface() { // Check insufficient balance before sending if (usdcBalanceRaw != null && usdcAmountRaw > usdcBalanceRaw) { - setError("Insufficient USDC balance"); + setPhase("review"); showToast("Insufficient USDC balance", "error"); txInFlight.current = false; return; } - setError(null); - setTxSig(null); + const paidUi = toUi(usdcAmountRaw, USDC_DECIMALS); + const receivedUi = tokenAmountUi; + setFailureMessage(null); + setLiveSig(null); setLoading(true); + setPhase("signing"); try { // Resolve the configured settlement mint fresh at submit time so we do not // build the transaction with stale client state after an admin-side mint change. @@ -403,7 +378,8 @@ export function BuyPrimaryInterface() { ) { const msg = `Configured USDC mint ${configuredUsdcMint.toBase58()} is missing or owned by an unexpected token program on ${currentCluster}.`; - setError(msg); + setFailureMessage(msg); + setPhase("failed"); showToast(msg, "error"); return; } @@ -426,7 +402,8 @@ export function BuyPrimaryInterface() { if (!bunkercashMintInfo) { const msg = "The BunkerCash mint PDA is not initialized for this program yet."; - setError(msg); + setFailureMessage(msg); + setPhase("failed"); showToast(msg, "error"); return; } @@ -496,16 +473,26 @@ export function BuyPrimaryInterface() { connection, wallet, transaction: tx, + onSigned: (signature) => { + setLiveSig(signature); + setPhase("pending"); + }, }); - setTxSig(sig); + setReceipt([ + { k: "Paid", v: `${paidUi} USDC` }, + { k: "Received (est.)", v: `${receivedUi} BNKR` }, + { k: "Signature", v: `${sig.slice(0, 5)}…${sig.slice(-4)}` }, + ]); + setLiveSig(sig); + setPhase("success"); setUsdcAmount(""); void fetchPoolState(); invalidateTransactionCache(); - showToast(`Transaction submitted. Tx: ${sig.slice(0, 8)}…`, "success"); + showToast(`Purchase confirmed. Tx: ${sig.slice(0, 8)}…`, "success"); } catch (e: unknown) { if (isWalletRejection(e)) { - setError("Transaction was rejected in your wallet."); + setPhase("review"); showToast("Transaction rejected by wallet", "warning"); } else if (e instanceof SendTransactionError) { const logs = await e.getLogs(connection); @@ -513,11 +500,13 @@ export function BuyPrimaryInterface() { console.error('Deposit transaction logs:', logs); } const msg = e.message || "Transaction failed"; - setError(msg); + setFailureMessage(msg); + setPhase("failed"); showToast(msg, "error"); } else { const msg = e instanceof Error ? e.message : "Transaction failed"; - setError(msg); + setFailureMessage(msg); + setPhase("failed"); showToast(msg, "error"); } } finally { @@ -526,214 +515,162 @@ export function BuyPrimaryInterface() { } }; + // ---- CTA state ---- + const ctaKind: CtaKind = "primary"; + let ctaLabel = "Review purchase"; + let ctaDisabled = false; + let ctaAction: (() => void) | undefined; if (!publicKey) { - return ( -
-

Connect your wallet to continue.

- -
- ) + ctaLabel = "Connect wallet"; + ctaAction = () => openConnect(true); + } else if (poolError) { + ctaLabel = "Pool unavailable"; + ctaDisabled = true; + } else if (!poolState) { + ctaLabel = "Loading pool data…"; + ctaDisabled = true; + } else if (!usdcMint || !supportsUsdcDeposits) { + ctaLabel = "Unsupported network"; + ctaDisabled = true; + } else if (!usdcAmountRaw || usdcAmountRaw <= BigInt(0)) { + ctaLabel = "Enter an amount"; + ctaDisabled = true; + } else if (inputError) { + ctaLabel = inputError.startsWith("Amount exceeds") + ? "Insufficient USDC" + : "Review purchase"; + ctaDisabled = true; + } else { + ctaAction = () => { + setPhase("review"); + setSheetOpen(true); + }; } - if (poolError || !poolState) { - return ( -
- {!poolError ? ( -

Loading pool price…

- ) : poolError === 'not_initialized' ? ( -
-

- The pool account is not initialized on this cluster yet. -

-
-
- Program: {PROGRAM_ID.toBase58()} -
-
- Pool PDA: {poolPda.toBase58()} -
-
- -
- ) : ( -

{poolError}

- )} -
- ) - } - - if (!usdcMint) { - return ( -
-
- -

- Unsupported Network -

-

- This deployment currently supports a configured USDC mint on - devnet/testnet only. Set `NEXT_PUBLIC_USDC_MINT` if you are using a - different supported USDC mint. -

-
-
- ); - } + const rateFmt = pricePerToken != null ? pricePerToken.toFixed(4) : "—"; + const feePct = formatPercentFromBps(poolState?.purchaseFeeBps ?? 0); + + const sheetRows: SheetRow[] = [ + { k: "You pay", v: `${usdcAmount || "0"} USDC`, strong: true }, + { k: "Reference rate", v: `1 BNKR = ${rateFmt} USDC` }, + { + k: "Protocol fee", + v: + purchaseFeeRaw != null && purchaseFeeRaw > 0n + ? `${feePct}% (${toUi(purchaseFeeRaw, USDC_DECIMALS)} USDC)` + : `${feePct}%`, + }, + { k: "Est. network fee", v: "0.000005 SOL" }, + { + k: "You receive (est.)", + v: `${tokenAmountUi || "0"} BNKR`, + strong: true, + tone: "mint", + highlight: true, + }, + ]; return ( -
-
-
-
-
- Reference Rate - - {poolRefreshing && ( - - - refreshing - - )} -
-
- ${pricePerToken != null ? pricePerToken.toFixed(2) : "—"} per - token -
-
-
-
- Pricing Method -
-
Protocol-defined
-
-
-
- -

- Purchases are made in USDC — BunkerCash does not accept SOL. Keep a small - amount of SOL in your wallet to cover Solana network fees. -

- -
-
-
- - You provide - - - Balance: {usdcBalance ?? "—"} - -
-
- setUsdcAmount(e.target.value)} - placeholder="0.00" - className="min-w-0 flex-1 bg-transparent text-2xl font-bold outline-none placeholder:text-neutral-800 sm:text-3xl" - /> -
- USDC -
-
-
- -
-
- -
-
- -
-
- - You receive - -
-
-
- {tokenAmountUi || "0"} -
-
- - BunkerCash - -
-
-
-
- Purchase fee - - {purchaseFeeRaw != null ? `${toUi(purchaseFeeRaw, USDC_DECIMALS)} USDC` : "0 USDC"} ({formatPercentFromBps(poolState.purchaseFeeBps)}%) - -
-
- Net investment - {netInvestmentRaw != null ? `${toUi(netInvestmentRaw, USDC_DECIMALS)} USDC` : "0 USDC"} -
-
-
+ <> + + USDC converts at the live reference rate. BNKR is minted directly to + your wallet — keep a small amount of SOL to cover network fees. + + + setUsdcAmount(usdcBalance) + : undefined + } + error={inputError} + /> + + + +
+ 1 BNKR = {rateFmt} USDC + {feePct}% + 0.000005 SOL + + Minted directly to your wallet +
- {error && ( -
- {error} + {poolError === "not_initialized" && ( +
+ + + Pool not initialized on this cluster + + + Program {PROGRAM_ID.toBase58()} · Pool {poolPda.toBase58()} + +
)} {usdcMintError && ( -
+
Failed to load configured USDC mint details: {usdcMintError}
)} - {!usdcMintError && !supportsUsdcDeposits && usdcBalance && ( -
- Detected {usdcBalance} USDC in your wallet, but the configured mint is unsupported for this deployment. Ask the team to verify the selected USDC mint. -
- )} - {txSig && ( -
- Success. Tx: {txSig.slice(0, 8)}…{txSig.slice(-8)} -
- )} - - {inputError && usdcAmount && ( -
- {inputError} + {!usdcMintError && usdcMint && !supportsUsdcDeposits && ( +
+ The configured USDC mint is unsupported for this deployment. Ask the + team to verify the selected mint.
)} - - -
-
- Displayed values are interface values only and do not constitute a - guarantee of value, liquidity, or future settlement. -
-
- Network:{" "} - {currentCluster} | - Mint: {usdcMint?.toBase58().slice(0, 4)}... - {usdcMint?.toBase58().slice(-4)} -
-
-
+ {loading ? "Processing…" : ctaLabel} + + + + Displayed values are interface values only and do not constitute a + guarantee of value, liquidity, or future settlement. + + + { + setSheetOpen(false); + setPhase("review"); + }} + onConfirm={() => void handleBuy()} + onRetry={() => setPhase("review")} + onDone={() => { + setSheetOpen(false); + setPhase("review"); + }} + /> + ); } diff --git a/ts/apps/web/components/SupportRequestForm.tsx b/ts/apps/web/components/SupportRequestForm.tsx index 9451739..0f4d4a2 100644 --- a/ts/apps/web/components/SupportRequestForm.tsx +++ b/ts/apps/web/components/SupportRequestForm.tsx @@ -1,9 +1,7 @@ "use client"; import { FormEvent, useState } from "react"; -import { Loader2, Send } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/design/icons"; interface SupportRequestFormProps { supportEmail: string; @@ -20,10 +18,12 @@ function getErrorMessage(value: unknown, fallback: string): string { ) { return value.error; } - return fallback; } +const inputClass = + "h-10 w-full rounded-lg border border-line bg-surface-2 px-3 text-[13.5px] text-ink placeholder:text-ink-3 focus:border-mint-line focus:outline-none disabled:opacity-50"; + export function SupportRequestForm({ supportEmail, initialSource, @@ -50,9 +50,7 @@ export function SupportRequestForm({ try { const response = await fetch("/api/support", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...form, source: initialSource, @@ -63,7 +61,9 @@ export function SupportRequestForm({ const data = await response.json(); if (!response.ok) { - throw new Error(getErrorMessage(data, "Failed to submit support request")); + throw new Error( + getErrorMessage(data, "Failed to submit support request"), + ); } setSuccess( @@ -89,121 +89,126 @@ export function SupportRequestForm({ } return ( -
-
-
- - +
+
-
- - +
-
- - +
-
- + - + - setForm((current) => ({ ...current, country: event.target.value })) + onChange={(e) => + setForm((c) => ({ ...c, country: e.target.value })) } placeholder="Italy" autoComplete="country-name" disabled={submitting} + className={inputClass} /> -
+
-
- - + Subject + - setForm((current) => ({ ...current, subject: event.target.value })) + onChange={(e) => + setForm((c) => ({ ...c, subject: e.target.value })) } placeholder="How can we help?" required disabled={submitting} + className={inputClass} /> -
+ -
- +