Web3 — Send / Receive (address · QR · confirm)
A glassy two-tab wallet panel for sending and receiving fictional tokens on a mock chain. Send combines recipient validation with .nova name resolution, a token picker with live balances, amount-to-fiat sync with MAX, a network fee summary, and a review-confirm-sign flow that ends with a copyable transaction hash. Receive renders a CSS-grid QR block seeded from your address, network and optional request amount, plus one-tap copy and payment-link sharing.
MCP
Code
:root {
--bg: #0a0b0f;
--surface: #13151c;
--surface-2: #1b1e27;
--elevated: #23262f;
--text: #e9ecf2;
--muted: #8a90a2;
--line: rgba(255, 255, 255, 0.08);
--line-2: rgba(255, 255, 255, 0.16);
--accent: #7c5cff;
--accent-2: #00e0c6;
--accent-glow: rgba(124, 92, 255, 0.45);
--pos: #26d07c;
--neg: #ff4d6d;
--warn: #ffb347;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--r-pill: 999px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
background-image:
radial-gradient(900px 500px at 12% -8%, rgba(124, 92, 255, 0.16), transparent 60%),
radial-gradient(700px 460px at 92% 8%, rgba(0, 224, 198, 0.1), transparent 55%);
color: var(--text);
font-family: "Space Grotesk", system-ui, sans-serif;
line-height: 1.5;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 32px 16px 56px;
}
.mono {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.stage {
width: 100%;
max-width: 440px;
}
/* ---------- wallet shell ---------- */
.wallet {
position: relative;
border-radius: var(--r-lg);
background: linear-gradient(180deg, rgba(35, 38, 47, 0.7), rgba(19, 21, 28, 0.92));
border: 1px solid var(--line);
backdrop-filter: blur(16px);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 30px 60px -28px rgba(0, 0, 0, 0.75);
overflow: hidden;
}
.wallet::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, rgba(124, 92, 255, 0.55), transparent 40%, rgba(0, 224, 198, 0.35));
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0.6;
}
.wallet__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 20px 14px;
}
.wallet__id {
display: flex;
align-items: center;
gap: 12px;
}
.logo {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: var(--r-md);
font-size: 20px;
color: #fff;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: 0 6px 20px -6px var(--accent-glow);
}
.wallet__name {
margin: 0;
font-weight: 600;
font-size: 15px;
}
.wallet__net {
margin: 1px 0 0;
font-size: 12px;
color: var(--muted);
}
.wallet__bal {
text-align: right;
display: flex;
flex-direction: column;
}
.wallet__bal-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.wallet__bal-val {
font-family: "JetBrains Mono", monospace;
font-weight: 700;
font-size: 17px;
}
/* ---------- tabs ---------- */
.tabs {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin: 4px 20px 0;
padding: 4px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-pill);
}
.tab {
position: relative;
z-index: 1;
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-weight: 600;
font-size: 14px;
padding: 9px 0;
border-radius: var(--r-pill);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
transition: color 0.2s ease;
}
.tab.is-active {
color: var(--text);
}
.tab__glow {
position: absolute;
z-index: 0;
top: 4px;
bottom: 4px;
left: 4px;
width: calc(50% - 4px);
border-radius: var(--r-pill);
background: linear-gradient(135deg, rgba(124, 92, 255, 0.85), rgba(124, 92, 255, 0.55));
box-shadow: 0 6px 18px -8px var(--accent-glow);
transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
}
.tabs[data-active="receive"] .tab__glow {
transform: translateX(calc(100% + 4px));
}
/* ---------- panels ---------- */
.panel {
position: relative;
padding: 18px 20px 22px;
}
.panel:not(.is-active) {
display: none;
}
.panel.is-active {
animation: rise 0.28s ease;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(6px);
}
}
.form {
display: flex;
flex-direction: column;
gap: 14px;
}
/* ---------- fields ---------- */
.field {
display: flex;
flex-direction: column;
gap: 7px;
}
.field__row,
.field__label {
display: flex;
align-items: center;
justify-content: space-between;
}
.field__label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.opt {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: var(--muted);
opacity: 0.7;
}
.field__sub {
font-size: 12px;
color: var(--muted);
}
.field__sub em {
color: var(--text);
font-style: normal;
}
.field__hint {
margin: 0;
font-size: 12px;
color: var(--muted);
transition: color 0.2s ease;
}
.field__wrap {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px 4px 12px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.field__wrap:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.field__input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
color: var(--text);
font-size: 14px;
padding: 9px 0;
outline: none;
}
.field__input::placeholder {
color: rgba(138, 144, 162, 0.7);
}
/* validation states */
.field__wrap.is-valid {
border-color: rgba(38, 208, 124, 0.55);
}
.field__wrap.is-invalid {
border-color: rgba(255, 77, 109, 0.6);
}
.field__wrap.is-valid::after,
.field__wrap.is-invalid::after {
font-size: 14px;
margin-right: 4px;
}
.field__hint.is-error {
color: var(--neg);
}
.field__hint.is-ok {
color: var(--pos);
}
.field__hint.is-loading {
color: var(--accent-2);
}
/* ---------- token select ---------- */
.token-select {
position: relative;
}
.token-select__btn {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
color: var(--text);
font: inherit;
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease;
}
.token-select__btn:hover {
border-color: var(--line-2);
}
.token-ic {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: var(--r-pill);
font-weight: 700;
font-size: 13px;
color: #0a0b0f;
flex-shrink: 0;
}
.token-ic[data-token="NOVA"] { background: linear-gradient(135deg, #7c5cff, #b39bff); color: #fff; }
.token-ic[data-token="LUM"] { background: linear-gradient(135deg, #00e0c6, #7af5e6); }
.token-ic[data-token="USDX"] { background: linear-gradient(135deg, #26d07c, #8df0bc); }
.token-ic[data-token="ASTR"] { background: linear-gradient(135deg, #ffb347, #ffd79a); }
.token-meta {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
text-align: left;
}
.token-sym {
font-weight: 600;
font-size: 14px;
}
.token-bal {
font-size: 12px;
color: var(--muted);
}
.caret {
color: var(--muted);
font-size: 11px;
transition: transform 0.2s ease;
}
.token-select__btn[aria-expanded="true"] .caret {
transform: rotate(180deg);
}
.token-menu {
position: absolute;
z-index: 20;
top: calc(100% + 6px);
left: 0;
right: 0;
margin: 0;
padding: 6px;
list-style: none;
background: var(--elevated);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
box-shadow: 0 22px 44px -20px rgba(0, 0, 0, 0.8);
animation: rise 0.16s ease;
}
.token-menu li {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 10px;
border-radius: var(--r-sm);
cursor: pointer;
}
.token-menu li:hover,
.token-menu li.is-focus {
background: var(--surface-2);
}
.token-menu .token-bal {
margin-left: auto;
color: var(--text);
}
/* ---------- amount ---------- */
.amount {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 12px 4px 14px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.amount:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.amount__in {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
color: var(--text);
font-size: 20px;
font-weight: 500;
padding: 11px 0;
outline: none;
}
.amount__in::placeholder {
color: rgba(138, 144, 162, 0.55);
}
.amount__side {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.amount__sym {
font-family: "JetBrains Mono", monospace;
font-size: 13px;
font-weight: 600;
color: var(--muted);
}
.amount__fiat {
margin: 0;
padding-left: 2px;
font-size: 13px;
color: var(--muted);
}
.chip-btn {
border: 1px solid var(--line-2);
background: rgba(124, 92, 255, 0.14);
color: var(--accent);
font: inherit;
font-weight: 700;
font-size: 11px;
letter-spacing: 0.04em;
padding: 5px 9px;
border-radius: var(--r-pill);
cursor: pointer;
transition: background 0.16s ease, transform 0.08s ease;
}
.chip-btn:hover {
background: rgba(124, 92, 255, 0.24);
}
.chip-btn:active {
transform: scale(0.94);
}
.ghost-btn {
border: 0;
background: var(--elevated);
color: var(--text);
font: inherit;
font-weight: 600;
font-size: 12px;
padding: 7px 11px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background 0.16s ease;
flex-shrink: 0;
}
.ghost-btn:hover {
background: #2c303b;
}
/* ---------- fee summary ---------- */
.fee {
display: flex;
flex-direction: column;
gap: 8px;
padding: 13px 14px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.fee__row {
display: flex;
justify-content: space-between;
font-size: 13px;
}
.fee__row span:first-child {
color: var(--muted);
}
.fee__row--muted span {
color: var(--muted);
}
.fee__row--total {
margin-top: 4px;
padding-top: 9px;
border-top: 1px dashed var(--line-2);
font-weight: 600;
}
.fee__row--total span:first-child {
color: var(--text);
}
/* ---------- buttons ---------- */
.primary,
.secondary {
font: inherit;
font-weight: 600;
font-size: 15px;
padding: 13px 18px;
border-radius: var(--r-md);
cursor: pointer;
transition: transform 0.08s ease, box-shadow 0.2s ease, opacity 0.2s ease, filter 0.2s ease;
}
.primary {
border: 0;
color: #fff;
background: linear-gradient(135deg, var(--accent), #9b82ff);
box-shadow: 0 12px 28px -12px var(--accent-glow);
}
.primary:hover:not(:disabled) {
filter: brightness(1.06);
}
.primary:active:not(:disabled) {
transform: translateY(1px);
}
.primary:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
}
.primary--glow {
box-shadow: 0 0 0 1px rgba(124, 92, 255, 0.4), 0 16px 36px -10px var(--accent-glow);
}
.secondary {
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--text);
}
.secondary:hover {
background: var(--elevated);
}
.secondary:active {
transform: translateY(1px);
}
.block {
width: 100%;
}
:where(button, input, [tabindex]):focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
/* ---------- confirm / success overlay ---------- */
.confirm {
position: absolute;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 16px;
background: rgba(10, 11, 15, 0.78);
backdrop-filter: blur(8px);
animation: rise 0.22s ease;
}
.confirm__card {
width: 100%;
max-width: 360px;
padding: 18px;
background: linear-gradient(180deg, var(--elevated), var(--surface-2));
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
box-shadow: 0 30px 60px -24px rgba(0, 0, 0, 0.85);
animation: pop 0.26s cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes pop {
from {
opacity: 0;
transform: scale(0.94) translateY(6px);
}
}
.confirm__risk {
display: flex;
gap: 8px;
font-size: 12.5px;
color: var(--warn);
background: rgba(255, 179, 71, 0.1);
border: 1px solid rgba(255, 179, 71, 0.28);
border-radius: var(--r-sm);
padding: 9px 11px;
margin-bottom: 14px;
}
.confirm__title {
margin: 0 0 12px;
font-size: 17px;
font-weight: 600;
}
.confirm__amount {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
padding: 14px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.confirm__big {
font-size: 28px;
font-weight: 700;
}
.confirm__sym {
font-family: "JetBrains Mono", monospace;
font-weight: 600;
color: var(--muted);
}
.confirm__fiat {
margin-left: auto;
font-size: 13px;
color: var(--muted);
}
.confirm__rows {
margin: 14px 0 0;
display: flex;
flex-direction: column;
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
}
.confirm__rows div {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 11px 13px;
background: var(--surface);
}
.confirm__rows dt {
color: var(--muted);
font-size: 13px;
}
.confirm__rows dd {
margin: 0;
font-size: 13px;
text-align: right;
}
.confirm__actions {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 10px;
margin-top: 16px;
}
.success__card {
text-align: center;
}
.success__check {
width: 56px;
height: 56px;
margin: 0 auto 12px;
display: grid;
place-items: center;
border-radius: var(--r-pill);
font-size: 26px;
color: #fff;
background: linear-gradient(135deg, var(--pos), #5ee3a3);
box-shadow: 0 0 0 6px rgba(38, 208, 124, 0.15);
animation: pop 0.4s cubic-bezier(0.32, 0.72, 0, 1);
}
.success__sub {
margin: 0 0 14px;
color: var(--muted);
}
.hash {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
margin-bottom: 14px;
}
.hash__label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.hash code {
flex: 1;
min-width: 0;
font-size: 12px;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---------- receive ---------- */
.receive {
display: flex;
flex-direction: column;
gap: 16px;
}
.qr-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
background:
radial-gradient(120% 120% at 50% 0%, rgba(124, 92, 255, 0.12), transparent 60%),
var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
}
.qr {
--cell: 7px;
display: grid;
grid-template-columns: repeat(21, var(--cell));
grid-template-rows: repeat(21, var(--cell));
gap: 1px;
padding: 14px;
background: #fff;
border-radius: var(--r-md);
box-shadow: 0 14px 34px -16px rgba(124, 92, 255, 0.6);
}
.qr i {
border-radius: 1px;
}
.qr i.on {
background: #0a0b0f;
}
.qr__cap {
margin: 0;
font-size: 13px;
color: var(--muted);
text-align: center;
}
.qr__cap strong {
color: var(--accent-2);
font-family: "JetBrains Mono", monospace;
}
.addr {
display: flex;
flex-direction: column;
gap: 7px;
}
.addr__label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.addr__box {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 9px 9px 13px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.addr__box code {
flex: 1;
min-width: 0;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* segmented network */
.seg {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
padding: 4px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-pill);
}
.seg__btn {
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-weight: 600;
font-size: 13px;
padding: 8px 0;
border-radius: var(--r-pill);
cursor: pointer;
transition: color 0.18s ease, background 0.18s ease;
}
.seg__btn:hover {
color: var(--text);
}
.seg__btn.is-active {
color: #fff;
background: linear-gradient(135deg, rgba(124, 92, 255, 0.85), rgba(124, 92, 255, 0.5));
box-shadow: 0 6px 16px -8px var(--accent-glow);
}
/* ---------- toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%) translateY(12px);
z-index: 80;
padding: 11px 16px;
background: var(--elevated);
border: 1px solid var(--line-2);
border-radius: var(--r-pill);
font-size: 13.5px;
font-weight: 500;
color: var(--text);
box-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.7);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease;
}
.toast.is-show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.toast.is-ok {
border-color: rgba(38, 208, 124, 0.5);
}
.toast.is-err {
border-color: rgba(255, 77, 109, 0.5);
}
/* ---------- responsive ---------- */
@media (max-width: 520px) {
body {
padding: 16px 10px 40px;
}
.wallet__head {
padding: 16px 16px 12px;
}
.tabs {
margin: 4px 16px 0;
}
.panel {
padding: 16px;
}
.wallet__bal-val {
font-size: 15px;
}
.amount__in {
font-size: 18px;
}
.confirm__big {
font-size: 24px;
}
.confirm__actions {
grid-template-columns: 1fr;
}
.qr {
--cell: 6px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}/* Web3 — Send / Receive · UI-only simulation (no real wallet, RPC or chain calls) */
(() => {
"use strict";
const $ = (id) => document.getElementById(id);
/* ---------------- mock data ---------------- */
const TOKENS = [
{ sym: "NOVA", name: "Nova", balance: 842.4, price: 12.84 },
{ sym: "LUM", name: "Lumen", balance: 1530.18, price: 1.92 },
{ sym: "USDX", name: "USD-X", balance: 6204.77, price: 1.0 },
{ sym: "ASTR", name: "Aster", balance: 96.05, price: 4.37 },
];
const NAME_BOOK = {
"vitalik.nova": "0x9f21c0de77a4b1e8d35c6a90b2f1e4a8d7c30b55",
"ana.nova": "0x44e1aa97c52b08d3f6e9017cbd2a85f4e6093c12",
"treasury.nova": "0xb70d35f8a14c9e26d08b5a7e3f6c20d194e8aa01",
};
const NET_FEE_NOVA = 0.00042; // flat mock network fee, paid in NOVA
const MY_ADDRESS = "0x7a3f4e9c1d8b6052aa31fe7740b2a85ef10ac41d";
/* ---------------- helpers ---------------- */
const fmt = (n, max = 6) =>
Number(n).toLocaleString("en-US", { maximumFractionDigits: max });
const fmtFiat = (n) =>
n.toLocaleString("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
});
const truncAddr = (a) =>
a.length > 14 ? `${a.slice(0, 6)}…${a.slice(-4)}` : a;
const isHexAddress = (v) => /^0x[0-9a-fA-F]{40}$/.test(v);
const isNovaName = (v) => /^[a-z0-9-]{2,32}\.nova$/i.test(v);
function randomHash() {
let h = "0x";
const chars = "0123456789abcdef";
for (let i = 0; i < 64; i++) h += chars[(Math.random() * 16) | 0];
return h;
}
/* toast */
const toastEl = $("toast");
let toastTimer = null;
function toast(msg, kind = "") {
toastEl.textContent = msg;
toastEl.hidden = false;
toastEl.className = `toast is-show${kind ? ` is-${kind}` : ""}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastEl.classList.remove("is-show");
}, 2200);
}
async function copyText(text, okMsg) {
try {
await navigator.clipboard.writeText(text);
toast(okMsg, "ok");
} catch {
toast("Copy not available in this context", "err");
}
}
/* animated number */
function animateNumber(el, to, format, dur = 600) {
const start = performance.now();
function tick(now) {
const p = Math.min(1, (now - start) / dur);
const eased = 1 - Math.pow(1 - p, 3);
el.textContent = format(to * eased);
if (p < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
/* ---------------- state ---------------- */
const state = {
token: TOKENS[0],
amount: 0,
recipientRaw: "",
resolvedAddr: null, // valid destination address or null
net: "Nova Chain",
reqAmount: "",
};
/* ---------------- header balance ---------------- */
const totalUsd = TOKENS.reduce((s, t) => s + t.balance * t.price, 0);
animateNumber($("totalBal"), totalUsd, (v) => fmtFiat(v), 900);
/* ---------------- tabs ---------------- */
const tabs = document.querySelectorAll(".tab");
const tabsWrap = document.querySelector(".tabs");
const panels = {
send: $("panel-send"),
receive: $("panel-receive"),
};
function activateTab(name) {
tabs.forEach((t) => {
const active = t.dataset.tab === name;
t.classList.toggle("is-active", active);
t.setAttribute("aria-selected", String(active));
t.tabIndex = active ? 0 : -1;
});
tabsWrap.dataset.active = name;
Object.entries(panels).forEach(([k, p]) => {
p.classList.toggle("is-active", k === name);
p.hidden = k !== name;
});
}
tabs.forEach((t) => {
t.addEventListener("click", () => activateTab(t.dataset.tab));
t.addEventListener("keydown", (e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const next = t.dataset.tab === "send" ? "receive" : "send";
activateTab(next);
document.querySelector(`.tab[data-tab="${next}"]`).focus();
});
});
activateTab("send");
/* ---------------- token select ---------------- */
const tokenBtn = $("tokenSel");
const tokenMenu = $("tokenMenu");
function renderTokenMenu() {
tokenMenu.innerHTML = "";
TOKENS.forEach((t) => {
const li = document.createElement("li");
li.setAttribute("role", "option");
li.setAttribute("aria-selected", String(t.sym === state.token.sym));
li.tabIndex = 0;
li.innerHTML = `
<span class="token-ic" data-token="${t.sym}">${t.sym[0]}</span>
<span class="token-meta">
<span class="token-sym">${t.sym}</span>
<span class="token-bal mono">${t.name} · ${fmtFiat(t.price)}</span>
</span>
<span class="token-bal mono">${fmt(t.balance, 2)}</span>`;
const pick = () => selectToken(t);
li.addEventListener("click", pick);
li.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
pick();
}
if (e.key === "Escape") closeMenu(true);
});
tokenMenu.appendChild(li);
});
}
function openMenu() {
renderTokenMenu();
tokenMenu.hidden = false;
tokenBtn.setAttribute("aria-expanded", "true");
}
function closeMenu(refocus = false) {
tokenMenu.hidden = true;
tokenBtn.setAttribute("aria-expanded", "false");
if (refocus) tokenBtn.focus();
}
tokenBtn.addEventListener("click", () =>
tokenMenu.hidden ? openMenu() : closeMenu()
);
document.addEventListener("click", (e) => {
if (!$("tokenSelect").contains(e.target)) closeMenu();
});
function selectToken(t) {
const menuWasOpen = !tokenMenu.hidden;
state.token = t;
tokenBtn.querySelector(".token-ic").dataset.token = t.sym;
tokenBtn.querySelector(".token-ic").textContent = t.sym[0];
$("selSym").textContent = t.sym;
$("selBal").textContent = fmt(t.balance, 2);
$("amtBalance").textContent = fmt(t.balance, 2);
$("amtSym").textContent = t.sym;
closeMenu(menuWasOpen);
syncAmount();
}
/* ---------------- recipient: validate + mock ENS resolve ---------------- */
const recipientIn = $("recipient");
const recipientWrap = $("recipientWrap");
const recipientHint = $("recipientHint");
let resolveTimer = null;
function setRecipientState(cls, hint, hintCls) {
recipientWrap.classList.remove("is-valid", "is-invalid");
if (cls) recipientWrap.classList.add(cls);
recipientHint.textContent = hint;
recipientHint.className = `field__hint${hintCls ? ` ${hintCls}` : ""}`;
}
function handleRecipientInput() {
clearTimeout(resolveTimer);
const v = recipientIn.value.trim();
state.recipientRaw = v;
state.resolvedAddr = null;
if (!v) {
setRecipientState(null, "Enter a Nova address or an .nova name.", "");
updateReview();
return;
}
if (isHexAddress(v)) {
if (v.toLowerCase() === MY_ADDRESS.toLowerCase()) {
setRecipientState("is-invalid", "That is your own address.", "is-error");
} else {
state.resolvedAddr = v;
setRecipientState("is-valid", "Valid Nova Chain address.", "is-ok");
}
updateReview();
return;
}
if (isNovaName(v)) {
setRecipientState(null, `Resolving ${v}…`, "is-loading");
resolveTimer = setTimeout(() => {
const addr =
NAME_BOOK[v.toLowerCase()] ||
"0x" +
Array.from(v)
.map((c) => c.charCodeAt(0).toString(16))
.join("")
.padEnd(40, "5")
.slice(0, 40);
state.resolvedAddr = addr;
setRecipientState(
"is-valid",
`${v} → ${truncAddr(addr)}`,
"is-ok"
);
updateReview();
}, 650);
updateReview();
return;
}
setRecipientState(
"is-invalid",
"Not a valid address or .nova name.",
"is-error"
);
updateReview();
}
recipientIn.addEventListener("input", handleRecipientInput);
$("pasteBtn").addEventListener("click", async () => {
try {
const text = await navigator.clipboard.readText();
if (!text) throw new Error();
recipientIn.value = text.trim();
} catch {
// clipboard blocked in sandboxed demos — fall back to a sample address
recipientIn.value = NAME_BOOK["ana.nova"];
toast("Pasted sample address (clipboard unavailable)");
}
handleRecipientInput();
recipientIn.focus();
});
/* ---------------- amount / fiat sync ---------------- */
const amountIn = $("amount");
function parseAmount(v) {
const n = parseFloat(String(v).replace(/,/g, ""));
return Number.isFinite(n) && n >= 0 ? n : 0;
}
function syncAmount() {
state.amount = parseAmount(amountIn.value);
const usd = state.amount * state.token.price;
$("fiatOut").textContent = `≈ ${fmtFiat(usd)} USD`;
$("feeNet").textContent = `${NET_FEE_NOVA} NOVA`;
$("feeTotal").textContent = `${fmt(state.amount)} ${state.token.sym}`;
updateReview();
}
amountIn.addEventListener("input", () => {
// keep only digits + one decimal point
amountIn.value = amountIn.value
.replace(/[^\d.]/g, "")
.replace(/(\..*)\./g, "$1");
syncAmount();
});
$("maxBtn").addEventListener("click", () => {
let max = state.token.balance;
if (state.token.sym === "NOVA") max = Math.max(0, max - NET_FEE_NOVA);
amountIn.value = String(max);
syncAmount();
toast(`Max ${state.token.sym} applied`);
});
/* ---------------- review → confirm → success ---------------- */
const reviewBtn = $("reviewBtn");
function updateReview() {
const overBalance = state.amount > state.token.balance;
reviewBtn.disabled = !(
state.resolvedAddr &&
state.amount > 0 &&
!overBalance
);
reviewBtn.textContent = overBalance
? "Insufficient balance"
: "Review transfer";
}
reviewBtn.addEventListener("click", () => {
const usd = state.amount * state.token.price;
animateNumber($("cAmount"), state.amount, (v) => fmt(v), 450);
$("cSym").textContent = state.token.sym;
$("cFiat").textContent = `≈ ${fmtFiat(usd)}`;
$("cTo").textContent = truncAddr(state.resolvedAddr);
$("cTo").title = state.resolvedAddr;
$("cFee").textContent = `${NET_FEE_NOVA} NOVA`;
$("confirm").hidden = false;
$("signBtn").disabled = false;
$("signBtn").textContent = "Sign & send";
$("cancelBtn").focus();
});
$("cancelBtn").addEventListener("click", () => {
$("confirm").hidden = true;
reviewBtn.focus();
});
$("signBtn").addEventListener("click", () => {
const btn = $("signBtn");
btn.disabled = true;
btn.textContent = "Signing…";
setTimeout(() => {
btn.textContent = "Broadcasting…";
setTimeout(() => {
// mock balance update
state.token.balance = Math.max(0, state.token.balance - state.amount);
$("selBal").textContent = fmt(state.token.balance, 2);
$("amtBalance").textContent = fmt(state.token.balance, 2);
$("confirm").hidden = true;
$("sAmount").textContent = `${fmt(state.amount)} ${
state.token.sym
} sent to ${truncAddr(state.resolvedAddr)}`;
$("sHash").textContent = randomHash();
$("success").hidden = false;
$("doneBtn").focus();
toast("Transaction confirmed", "ok");
}, 900);
}, 800);
});
$("copyHash").addEventListener("click", () =>
copyText($("sHash").textContent, "Tx hash copied")
);
$("doneBtn").addEventListener("click", () => {
$("success").hidden = true;
amountIn.value = "";
recipientIn.value = "";
state.resolvedAddr = null;
state.recipientRaw = "";
setRecipientState(null, "Enter a Nova address or an .nova name.", "");
syncAmount();
});
/* ---------------- receive: QR + copy + network + request ---------------- */
const qrEl = $("qr");
const SIZE = 21;
// tiny deterministic hash → pseudo-random bit stream
function seededBits(str) {
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return () => {
h ^= h << 13;
h ^= h >>> 17;
h ^= h << 5;
return (h >>> 0) % 100 < 48;
};
}
function inFinder(r, c) {
const zones = [
[0, 0],
[0, SIZE - 7],
[SIZE - 7, 0],
];
return zones.some(([zr, zc]) => r >= zr && r < zr + 7 && c >= zc && c < zc + 7);
}
function finderOn(r, c) {
const zones = [
[0, 0],
[0, SIZE - 7],
[SIZE - 7, 0],
];
for (const [zr, zc] of zones) {
if (r >= zr && r < zr + 7 && c >= zc && c < zc + 7) {
const lr = r - zr;
const lc = c - zc;
const ring =
lr === 0 || lr === 6 || lc === 0 || lc === 6;
const core = lr >= 2 && lr <= 4 && lc >= 2 && lc <= 4;
return ring || core;
}
}
return false;
}
function drawQR() {
const seed = `${MY_ADDRESS}|${state.net}|${state.reqAmount}`;
const next = seededBits(seed);
qrEl.innerHTML = "";
const frag = document.createDocumentFragment();
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const cell = document.createElement("i");
const on = inFinder(r, c) ? finderOn(r, c) : next();
if (on) cell.className = "on";
frag.appendChild(cell);
}
}
qrEl.appendChild(frag);
updateQrCaption();
}
function updateQrCaption() {
const amt = parseAmount(state.reqAmount);
$("qrCap").innerHTML = amt
? `Requesting <strong>${fmt(amt)} NOVA</strong> on ${state.net}`
: `Scan to send NOVA on ${state.net}`;
}
$("copyAddr").addEventListener("click", () =>
copyText(MY_ADDRESS, "Address copied")
);
document.querySelectorAll(".seg__btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".seg__btn").forEach((b) => {
const on = b === btn;
b.classList.toggle("is-active", on);
b.setAttribute("aria-checked", String(on));
});
state.net = btn.dataset.net;
drawQR();
toast(`Receiving on ${state.net}`);
});
});
const reqIn = $("reqAmt");
reqIn.addEventListener("input", () => {
reqIn.value = reqIn.value.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1");
state.reqAmount = reqIn.value;
drawQR();
});
$("shareBtn").addEventListener("click", () => {
const amt = parseAmount(state.reqAmount);
const link = `nova:${MY_ADDRESS}?net=${encodeURIComponent(state.net)}${
amt ? `&amount=${amt}` : ""
}`;
copyText(link, "Payment link copied");
});
/* ---------------- init ---------------- */
selectToken(TOKENS[0]);
syncAmount();
drawQR();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Web3 — Send / Receive</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="stage">
<section class="wallet" aria-label="Send and receive crypto">
<header class="wallet__head">
<div class="wallet__id">
<span class="logo" aria-hidden="true">◈</span>
<div>
<p class="wallet__name">Lumen Wallet</p>
<p class="wallet__net">Nova Chain · Mainnet</p>
</div>
</div>
<div class="wallet__bal">
<span class="wallet__bal-label">Total balance</span>
<span class="wallet__bal-val" id="totalBal">$12,480.55</span>
</div>
</header>
<div class="tabs" role="tablist" aria-label="Send or receive">
<button
class="tab is-active"
role="tab"
id="tab-send"
aria-selected="true"
aria-controls="panel-send"
data-tab="send"
>
<span aria-hidden="true">↗</span> Send
</button>
<button
class="tab"
role="tab"
id="tab-receive"
aria-selected="false"
aria-controls="panel-receive"
data-tab="receive"
tabindex="-1"
>
<span aria-hidden="true">↙</span> Receive
</button>
<span class="tab__glow" aria-hidden="true"></span>
</div>
<!-- ============ SEND ============ -->
<section
class="panel is-active"
id="panel-send"
role="tabpanel"
aria-labelledby="tab-send"
>
<div class="form" id="sendForm">
<label class="field" for="recipient">
<span class="field__label">Recipient</span>
<div class="field__wrap" id="recipientWrap">
<input
type="text"
id="recipient"
class="field__input mono"
placeholder="0x… or name.nova"
autocomplete="off"
spellcheck="false"
aria-describedby="recipientHint"
/>
<button type="button" class="ghost-btn" id="pasteBtn">Paste</button>
</div>
<p class="field__hint" id="recipientHint">
Enter a Nova address or an .nova name.
</p>
</label>
<label class="field" for="tokenSel">
<span class="field__label">Token</span>
<div class="token-select" id="tokenSelect">
<button
type="button"
class="token-select__btn"
id="tokenSel"
aria-haspopup="listbox"
aria-expanded="false"
>
<span class="token-ic" data-token="NOVA">N</span>
<span class="token-meta">
<span class="token-sym" id="selSym">NOVA</span>
<span class="token-bal mono" id="selBal">842.40</span>
</span>
<span class="caret" aria-hidden="true">▾</span>
</button>
<ul class="token-menu" id="tokenMenu" role="listbox" aria-label="Choose token" hidden></ul>
</div>
</label>
<label class="field" for="amount">
<div class="field__row">
<span class="field__label">Amount</span>
<span class="field__sub mono">
Balance <em id="amtBalance">842.40</em>
</span>
</div>
<div class="amount">
<input
type="text"
id="amount"
class="amount__in mono"
inputmode="decimal"
placeholder="0.00"
autocomplete="off"
aria-describedby="fiatOut"
/>
<div class="amount__side">
<button type="button" class="chip-btn" id="maxBtn">MAX</button>
<span class="amount__sym" id="amtSym">NOVA</span>
</div>
</div>
<p class="amount__fiat mono" id="fiatOut">≈ $0.00 USD</p>
</label>
<div class="fee" aria-label="Network fee summary">
<div class="fee__row">
<span>Network fee</span>
<span class="mono" id="feeNet">0.00042 NOVA</span>
</div>
<div class="fee__row fee__row--muted">
<span>Est. time</span>
<span class="mono">~ 12s</span>
</div>
<div class="fee__row fee__row--total">
<span>You send</span>
<span class="mono" id="feeTotal">0.00 NOVA</span>
</div>
</div>
<button type="button" class="primary" id="reviewBtn" disabled>
Review transfer
</button>
</div>
<!-- confirm overlay -->
<div class="confirm" id="confirm" hidden>
<div class="confirm__card" role="dialog" aria-modal="true" aria-labelledby="confirmTitle">
<div class="confirm__risk" id="confirmRisk">
<span aria-hidden="true">⚠</span>
Double-check the recipient. Transfers on Nova Chain are irreversible.
</div>
<h2 class="confirm__title" id="confirmTitle">Confirm transfer</h2>
<div class="confirm__amount">
<span class="confirm__big mono" id="cAmount">0.00</span>
<span class="confirm__sym" id="cSym">NOVA</span>
<span class="confirm__fiat mono" id="cFiat">≈ $0.00</span>
</div>
<dl class="confirm__rows">
<div><dt>To</dt><dd class="mono" id="cTo">0x0000…0000</dd></div>
<div><dt>Network</dt><dd>Nova Chain · Mainnet</dd></div>
<div><dt>Network fee</dt><dd class="mono" id="cFee">0.00042 NOVA</dd></div>
</dl>
<div class="confirm__actions">
<button type="button" class="secondary" id="cancelBtn">Cancel</button>
<button type="button" class="primary primary--glow" id="signBtn">
Sign & send
</button>
</div>
</div>
</div>
<!-- success overlay -->
<div class="confirm" id="success" hidden>
<div class="confirm__card success__card" role="dialog" aria-modal="true" aria-labelledby="successTitle">
<div class="success__check" aria-hidden="true">✓</div>
<h2 class="confirm__title" id="successTitle">Transfer sent</h2>
<p class="success__sub mono" id="sAmount">0.00 NOVA sent</p>
<div class="hash">
<span class="hash__label">Tx hash</span>
<code class="mono" id="sHash">0x…</code>
<button type="button" class="ghost-btn" id="copyHash">Copy</button>
</div>
<button type="button" class="secondary block" id="doneBtn">Done</button>
</div>
</div>
</section>
<!-- ============ RECEIVE ============ -->
<section
class="panel"
id="panel-receive"
role="tabpanel"
aria-labelledby="tab-receive"
hidden
>
<div class="receive">
<div class="qr-card">
<div class="qr" id="qr" role="img" aria-label="QR code for your wallet address"></div>
<p class="qr__cap" id="qrCap">Scan to send NOVA on Nova Chain</p>
</div>
<div class="addr">
<span class="addr__label">Your address</span>
<div class="addr__box">
<code class="mono" id="myAddr">0x7a3f4e9c1d8b6052aa31fe7740b2a85ef10ac41d</code>
<button type="button" class="ghost-btn" id="copyAddr">Copy</button>
</div>
</div>
<label class="field" for="netSel">
<span class="field__label">Network</span>
<div class="seg" id="netSeg" role="radiogroup" aria-label="Receive network">
<button type="button" class="seg__btn is-active" role="radio" aria-checked="true" data-net="Nova Chain">Nova</button>
<button type="button" class="seg__btn" role="radio" aria-checked="false" data-net="Lumen Chain">Lumen</button>
<button type="button" class="seg__btn" role="radio" aria-checked="false" data-net="Aster L2">Aster L2</button>
</div>
</label>
<label class="field" for="reqAmt">
<span class="field__label">Request amount <span class="opt">(optional)</span></span>
<div class="amount">
<input
type="text"
id="reqAmt"
class="amount__in mono"
inputmode="decimal"
placeholder="0.00"
autocomplete="off"
/>
<div class="amount__side">
<span class="amount__sym">NOVA</span>
</div>
</div>
</label>
<button type="button" class="secondary block" id="shareBtn">
Share request
</button>
</div>
</section>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<script src="script.js"></script>
</body>
</html>Send / Receive (address · QR · confirm)
A single wallet card with a pill tab switcher gliding between Send and Receive. The Send tab validates the recipient as you type — raw 0x addresses get instant green/red border states, while .nova names trigger a mock resolver that returns a truncated monospace address after a short delay. A token selector dropdown lists four fictional assets (NOVA, LUM, USDX, ASTR) with balances and prices, and the amount field keeps a live fiat estimate in sync, with a MAX button that reserves the network fee.
Pressing Review transfer opens a confirm dialog with an irreversibility warning, the animated amount, the resolved recipient and the fee breakdown. Sign & send walks through Signing → Broadcasting states before landing on a success card with a freshly generated 64-char tx hash you can copy, and the token balance is debited locally.
The Receive tab draws a 21×21 QR-style grid in pure CSS, deterministically seeded from your address, the selected network and the optional request amount — change either and the pattern redraws while the caption updates (“Requesting 25 NOVA on Aster L2”). Copy-address, share-payment-link and every state change surface through a small toast helper; tabs, the token listbox and the network radio group are all keyboard-operable.
UI-only simulation — no real wallet, RPC, or on-chain calls. Mock data, fictional tokens.