Files
polynote/app/page.tsx
Urko a32badd025
Some checks failed
build / lint (push) Failing after 36s
build / build (push) Has been skipped
initial commit
2026-01-29 16:13:04 +00:00

604 lines
20 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import Image from "next/image";
import { ethers } from "ethers";
import { LocaleSelector } from "@/components/LocaleSelector";
import { useI18n, type Locale, type MessageKey } from "@/hooks/useI18n";
import { useTheme } from "@/hooks/useTheme";
type StatusTone = "info" | "success" | "error";
type StatusState = {
text: string;
tone: StatusTone;
key?: string;
vars?: Record<string, string>;
};
type Eip1193Provider = {
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;
on?: (event: string, fn: (...args: unknown[]) => void | Promise<void>) => void;
removeListener?: (event: string, fn: (...args: unknown[]) => void | Promise<void>) => void;
providers?: Eip1193Provider[];
isTrustWallet?: boolean;
isTrust?: boolean;
};
type EthereumWindow = Window & {
ethereum?: Eip1193Provider & { providers?: Eip1193Provider[] };
trustwallet?: Eip1193Provider;
};
const formatChain = (chainId: number | null) => {
if (chainId === 137) return "Polygon";
if (typeof chainId === "number" && Number.isFinite(chainId)) return `Chain ${chainId}`;
return "—";
};
type MessageEntry = {
hash: string;
to: string;
from: string;
message: string;
createdAt: number;
};
export default function HomePage() {
const { locale, setLocale, t, localeOptions } = useI18n("en");
const [account, setAccount] = useState<string>("");
const [chainId, setChainId] = useState<number | null>(null);
const [txHash, setTxHash] = useState<string>("—");
const [status, setStatus] = useState<StatusState>({
text: t("status.waiting"),
key: "status.waiting",
tone: "info",
});
const [connecting, setConnecting] = useState<boolean>(false);
const [sending, setSending] = useState<boolean>(false);
const [connected, setConnected] = useState<boolean>(false);
const [history, setHistory] = useState<MessageEntry[]>([]);
const providerRef = useRef<Eip1193Provider | null>(null);
const signerRef = useRef<ethers.Signer | null>(null);
const formRef = useRef<HTMLFormElement | null>(null);
const chainLabel = formatChain(chainId);
const onPolygon = chainId === 137;
const statusClass =
status.tone === "success"
? "status-line status-ok"
: status.tone === "error"
? "status-line status-error"
: "status-line";
const chainPillClass = onPolygon ? "pill pill-live" : "pill pill-quiet";
const chainPillText = onPolygon ? "Polygon" : chainLabel || t("live.chainRequired");
const disableSend = !connected || sending;
const disableConnect = connecting;
const disableDisconnect = !connected || connecting;
const polygonscanBase = "https://polygonscan.com/tx/";
const hasTxHash = Boolean(txHash && txHash !== "—");
useEffect(() => {
if (status.key) {
setStatus((prev) => ({
...prev,
text: t(prev.key as MessageKey, prev.vars),
}));
}
}, [locale, status.key, status.vars, t]);
function setStatusMessage(
text: string,
tone: StatusTone = "info",
key?: string,
vars?: Record<string, string>
) {
setStatus({ text, tone, key, vars });
}
async function connectWallet() {
if (connecting) return;
setConnecting(true);
setTxHash("—");
setStatusMessage(t("status.preparingConnect"), "info", "status.preparingConnect");
await disconnect(false);
try {
await connectInjected();
} catch (err) {
console.error("Connection failed", err);
setStatusMessage(normalizeError(err, t("errors.connectionFailed"), t), "error");
resetState();
} finally {
setConnecting(false);
}
}
async function connectInjected() {
const injected = pickInjectedProvider();
if (!injected) {
setStatusMessage(t("errors.noWallet"), "error", "errors.noWallet");
return;
}
providerRef.current = injected;
attachProviderEvents(injected);
setStatusMessage(t("status.requesting"), "info", "status.requesting");
// Try to force a fresh permission prompt when possible
try {
await injected.request({
method: "wallet_requestPermissions",
params: [{ eth_accounts: {} }],
});
} catch (err) {
// Some wallets do not support this; fall back silently
console.warn("wallet_requestPermissions not supported or rejected", err);
}
const accounts = (await injected.request({
method: "eth_requestAccounts",
})) as string[];
if (!accounts || accounts.length === 0) {
throw new Error(t("errors.noAccounts"));
}
const browserProvider = new ethers.BrowserProvider(
injected as unknown as ethers.Eip1193Provider
);
const signer = await browserProvider.getSigner();
signerRef.current = signer;
const address = await signer.getAddress();
const network = await browserProvider.getNetwork();
setAccount(address);
setChainId(Number(network.chainId));
setConnected(true);
setStatusMessage(t("status.connectedInjected"), "success", "status.connectedInjected");
}
async function disconnect(showMessage = true) {
removeProviderEvents(providerRef.current);
providerRef.current = null;
signerRef.current = null;
resetState();
if (showMessage) setStatusMessage(t("status.disconnected"), "info", "status.disconnected");
}
function resetState() {
setConnected(false);
setAccount("");
setChainId(null);
setTxHash("—");
}
function attachProviderEvents(provider: Eip1193Provider) {
if (!provider?.on) return;
provider.on("accountsChanged", handleAccountsChanged);
provider.on("chainChanged", handleChainChanged);
provider.on("disconnect", handleDisconnectEvent);
}
function removeProviderEvents(provider: Eip1193Provider | null) {
if (!provider?.removeListener) return;
provider.removeListener("accountsChanged", handleAccountsChanged);
provider.removeListener("chainChanged", handleChainChanged);
provider.removeListener("disconnect", handleDisconnectEvent);
}
function handleDisconnectEvent() {
disconnect(true);
}
async function handleAccountsChanged(...args: unknown[]) {
const accounts = Array.isArray(args[0]) ? args[0] : [];
if (!accounts || accounts.length === 0) {
await disconnect(true);
return;
}
const first = accounts.find((acct): acct is string => typeof acct === "string");
if (!first) {
await disconnect(true);
return;
}
setAccount(first);
}
async function handleChainChanged(newChainId: unknown) {
if (typeof newChainId !== "string" && typeof newChainId !== "number") return;
const parsed = parseChainId(newChainId);
setChainId(parsed);
}
async function ensurePolygonChain() {
if (chainId === 137 || !providerRef.current) return true;
try {
await providerRef.current.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: "0x89" }],
});
setChainId(137);
return true;
} catch (err) {
console.warn("Chain switch rejected", err);
return false;
}
}
async function handleCopyHash() {
if (!hasTxHash) return;
try {
if (!("clipboard" in navigator)) {
throw new Error("Clipboard API unavailable");
}
await navigator.clipboard.writeText(txHash);
setStatusMessage(t("live.copySuccess"), "success", "live.copySuccess");
} catch (err) {
console.warn("Copy failed", err);
setStatusMessage(t("live.copyFailed"), "error", "live.copyFailed");
}
}
async function handleSendMessage(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formEl = event.currentTarget;
setTxHash("—");
if (!signerRef.current) {
setStatusMessage(t("status.connectFirst"), "error", "status.connectFirst");
return;
}
const formData = new FormData(event.currentTarget);
const message = (formData.get("message") || "").toString().trim();
let recipient = (formData.get("to") || "").toString().trim();
if (!message) {
setStatusMessage(t("status.messageEmpty"), "error", "status.messageEmpty");
return;
}
if (!recipient) recipient = account;
if (!ethers.isAddress(recipient)) {
setStatusMessage(t("status.recipientInvalid"), "error", "status.recipientInvalid");
return;
}
const recipientIsEoa = recipient.toLowerCase() === account.toLowerCase();
if (recipientIsEoa) {
setStatusMessage(t("status.eoaWarning"), "info", "status.eoaWarning");
}
const canUsePolygon = await ensurePolygonChain();
if (!canUsePolygon) {
setStatusMessage(t("status.switchPolygon"), "error", "status.switchPolygon");
return;
}
try {
setSending(true);
setStatusMessage(t("status.prepareTx"), "info", "status.prepareTx");
const data = ethers.hexlify(ethers.toUtf8Bytes(message));
const txRequest = { to: recipient, value: 0n, data };
let gasLimit: bigint;
try {
gasLimit = await signerRef.current.estimateGas(txRequest);
} catch (estimateErr) {
console.warn("Gas estimation failed, using fallback", estimateErr);
setStatusMessage(t("status.gasFallback"), "info", "status.gasFallback");
gasLimit = 250000n;
}
const feeData = await signerRef.current.provider?.getFeeData?.();
const feeOverrides =
feeData && feeData.maxFeePerGas && feeData.maxPriorityFeePerGas
? {
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
}
: {
// conservative Polygon fallbacks in gwei
maxFeePerGas: ethers.parseUnits("50", "gwei"),
maxPriorityFeePerGas: ethers.parseUnits("40", "gwei"),
};
let tx;
try {
tx = await signerRef.current.sendTransaction({
...txRequest,
gasLimit,
...feeOverrides,
});
} catch (sendErr) {
const code = (sendErr as { code?: number | string })?.code;
if (code === 4001 || code === "ACTION_REJECTED") {
setStatusMessage(t("status.rejected"), "error", "status.rejected");
throw sendErr;
}
// Fallback to legacy gasPrice if EIP-1559 style send fails or simulates a revert
setStatusMessage(t("status.feeFallback"), "info", "status.feeFallback");
const gasPrice =
(await signerRef.current.provider?.getGasPrice?.()) || ethers.parseUnits("50", "gwei");
tx = await signerRef.current.sendTransaction({
...txRequest,
gasLimit,
gasPrice,
});
}
setTxHash(tx.hash);
setStatusMessage(t("status.submitted", { hash: tx.hash }), "success", "status.submitted", {
hash: tx.hash,
});
await tx.wait();
setStatusMessage(t("status.onchain"), "success", "status.onchain");
formEl.reset();
setHistory((prev) => [
{
hash: tx.hash,
to: recipient,
from: account,
message,
createdAt: Date.now(),
},
...prev,
]);
} catch (err) {
console.error("Send failed", err);
setStatusMessage(normalizeError(err, t("errors.sendFailed"), t), "error");
} finally {
setSending(false);
}
}
const { theme, toggleTheme } = useTheme();
return (
<main className="page">
<header className="hero">
<div className="hero-row">
<div className="hero-text">
<div className="hero-topline">
<Image
src="/logo.svg"
alt="PolyNote logo"
width={52}
height={52}
className="logo-mark"
/>
<div className="eyebrow">{t("hero.eyebrow")}</div>
</div>
<h1>{t("hero.title")}</h1>
<p className="lede">{t("hero.lede")}</p>
</div>
<div className="hero-actions">
<LocaleSelector
locale={locale}
onChange={(code) => setLocale(code as Locale)}
options={localeOptions}
label={t("locale.label")}
searchPlaceholder={t("locale.search")}
/>
<button
type="button"
className="ghost theme-toggle"
onClick={toggleTheme}
aria-pressed={theme === "dark"}
title={t("theme.toggle")}
>
{theme === "dark" ? "🌙" : "☀️"}{" "}
{theme === "dark" ? t("theme.light") : t("theme.dark")}
</button>
</div>
</div>
</header>
<section className="grid">
<article className="card">
<div className="card-head">
<div>
<p className="label">{t("connection.title")}</p>
<p className="muted">{t("connection.subtitle")}</p>
</div>
<span className="pill pill-quiet">{t("connection.pill")}</span>
</div>
<div className="stack">
<div className="button-row">
<button
id="connectBtn"
type="button"
onClick={connectWallet}
disabled={disableConnect}
>
{connecting ? t("compose.sending") : t("connection.connect")}
</button>
<button
id="disconnectBtn"
type="button"
className="ghost"
onClick={() => disconnect(true)}
disabled={disableDisconnect}
>
{t("connection.disconnect")}
</button>
</div>
</div>
<dl className="meta">
<div>
<dt>{t("connection.account")}</dt>
<dd>{account || "—"}</dd>
</div>
<div>
<dt>{t("connection.chain")}</dt>
<dd>{chainLabel}</dd>
</div>
<div>
<dt>{t("connection.status")}</dt>
<dd>{connected ? t("connection.connected") : t("connection.notConnected")}</dd>
</div>
</dl>
</article>
<article className="card">
<div className="card-head">
<div>
<p className="label">{t("compose.title")}</p>
<p className="muted">{t("compose.subtitle")}</p>
</div>
<span className={chainPillClass}>{chainPillText}</span>
</div>
<form
id="messageForm"
ref={formRef}
className="stack"
noValidate
onSubmit={handleSendMessage}
>
<label className="input-label" htmlFor="toAddress">
{t("compose.recipientLabel")}{" "}
<span className="muted">{t("compose.recipientHint")}</span>
</label>
<input
id="toAddress"
name="to"
type="text"
placeholder="0x destination (optional)"
autoComplete="off"
/>
<label className="input-label" htmlFor="messageInput">
{t("compose.messageLabel")}
</label>
<textarea
id="messageInput"
name="message"
rows={4}
maxLength={280}
placeholder={t("compose.messagePlaceholder")}
disabled={!connected}
></textarea>
<p className="hint">{t("compose.messageHint")}</p>
<button id="sendBtn" type="submit" className="primary" disabled={disableSend}>
{sending ? t("compose.sending") : t("compose.send")}
</button>
</form>
</article>
<article className="card status-card">
<div className="card-head">
<div>
<p className="label">{t("live.title")}</p>
<p className="muted">{t("live.subtitle")}</p>
</div>
</div>
<div className={statusClass}>{status.text}</div>
<div className="status-line secondary">
<span className="dot"></span>
<span>{onPolygon ? t("live.chainReady") : t("live.chainRequired")}</span>
</div>
<div className="tx-box">
<div className="label">{t("live.lastTx")}</div>
<div className="hash">{txHash || "—"}</div>
<div className="tx-actions">
<button
type="button"
className="ghost copy-btn"
onClick={handleCopyHash}
disabled={!hasTxHash}
title={t("live.copy")}
>
{t("live.copy")}
</button>
<a
className={`link ${!hasTxHash ? "disabled" : ""}`}
href={hasTxHash ? `${polygonscanBase}${txHash}` : undefined}
target="_blank"
rel="noreferrer"
aria-disabled={!hasTxHash}
>
{t("live.viewOnPolygonscan")}
</a>
</div>
<div className="hint">{t("live.trackHint")}</div>
</div>
<div className="history">
<div className="history-head">
<div>
<p className="label">{t("history.title")}</p>
<p className="muted">{t("history.subtitle")}</p>
</div>
</div>
{history.length === 0 ? (
<div className="history-empty">{t("history.empty")}</div>
) : (
<ul className="history-list">
{history.map((item) => (
<li key={item.hash} className="history-item">
<div className="history-row">
<span className="pill pill-quiet">{t("history.sent")}</span>
<span className="history-time">
{new Date(item.createdAt).toLocaleTimeString()}
</span>
</div>
<div className="history-meta">
<span>
{t("history.from")}: {item.from}
</span>
<span>
{t("history.to")}: {item.to}
</span>
</div>
<div className="history-message">{item.message}</div>
<div className="history-hash">
{item.hash.slice(0, 10)}{item.hash.slice(-6)}
</div>
</li>
))}
</ul>
)}
</div>
</article>
</section>
</main>
);
}
function pickInjectedProvider(): Eip1193Provider | null {
if (typeof window === "undefined") return null;
const { ethereum, trustwallet } = window as EthereumWindow;
if (ethereum?.providers?.length) {
const trust = ethereum.providers.find((p) => p.isTrustWallet || p.isTrust);
if (trust) return trust;
return ethereum.providers[0];
}
if (trustwallet) return trustwallet;
return ethereum || null;
}
function parseChainId(chainId: string | number): number {
if (typeof chainId === "string" && chainId.startsWith("0x")) {
return parseInt(chainId, 16);
}
return Number(chainId);
}
function normalizeError(
err: unknown,
fallback: string,
t: (key: string, vars?: Record<string, string>) => string
): string {
if (!err) return fallback;
if (typeof err === "string") return err;
if (typeof err === "object") {
const maybeError = err as { code?: number | string; message?: string; reason?: string };
if (maybeError.code === 4001 || maybeError.message?.toLowerCase().includes("user rejected"))
return t("status.rejected");
if (maybeError.message) return maybeError.message;
if (maybeError.reason) return maybeError.reason;
}
return fallback;
}