605 lines
20 KiB
TypeScript
605 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?.getFeeData?.())?.gasPrice ||
|
|
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;
|
|
}
|