430 lines
21 KiB
HTML
430 lines
21 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Покупка билета — Shine Payments Devnet</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
--bg: #0f1218;
|
||
--panel: #171b24;
|
||
--text: #e8edf6;
|
||
--muted: #97a3b8;
|
||
--line: #2a3242;
|
||
--ok: #55d48a;
|
||
--warn: #ffbf5e;
|
||
--err: #ff7d7d;
|
||
--btn: #273247;
|
||
--btn-hover: #32415c;
|
||
--code: #1e2633;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body { font-family: Arial, sans-serif; margin: 20px; background: var(--bg); color: var(--text); }
|
||
.topbar { margin-bottom: 12px; }
|
||
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
|
||
.wrap { width: 100%; max-width: 1700px; }
|
||
h1 { margin: 8px 0; }
|
||
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); width: 100%; }
|
||
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
|
||
input { padding: 9px 10px; min-width: 240px; border: 1px solid var(--line); border-radius: 8px; background: #131824; color: var(--text); }
|
||
button { padding: 9px 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; background: var(--btn); color: var(--text); }
|
||
button:hover { background: var(--btn-hover); }
|
||
.muted { color: var(--muted); }
|
||
.ok { color: var(--ok); }
|
||
.warn { color: var(--warn); }
|
||
.err { color: var(--err); white-space: pre-wrap; }
|
||
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<div class="topbar"><a class="back" href="./index.html">← На главную</a></div>
|
||
<h1>Покупка билета (Devnet)</h1>
|
||
<div class="muted">Программа: <code id="programId"></code></div>
|
||
|
||
<div class="panel">
|
||
<div class="row">
|
||
<button id="connectBtn">Подключить кошелек</button>
|
||
<button id="refreshBtn">Обновить состояние</button>
|
||
</div>
|
||
<div id="walletInfo" class="muted"></div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h3>Текущее состояние (очередь 1)</h3>
|
||
<div id="stateInfo" class="muted">Загрузка...</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h3>Покупка билета в 1-й очереди</h3>
|
||
<div class="muted">Можно купить по USD или по SOL. В очередь и лимиты записываются USD-центы. Выплаты по тикетам считаются в USD, а переводятся в SOL по актуальному курсу Pyth в момент шага выплаты.</div>
|
||
<div class="row">
|
||
<label>Сумма (USD): <input id="amountUsd" value="20" /></label>
|
||
<label>Сумма (SOL): <input id="amountSol" value="0.1" /></label>
|
||
<label>Допуск (%): <input id="slippagePct" value="3" /></label>
|
||
</div>
|
||
<div class="row">
|
||
<label>Кошелек для выплаты: <input id="recipient" placeholder="по умолчанию ваш кошелек" /></label>
|
||
</div>
|
||
<div id="quoteInfo" class="muted"></div>
|
||
<div class="row">
|
||
<button id="buyUsdBtn">Купить по USD</button>
|
||
<button id="buySolBtn">Купить по SOL</button>
|
||
</div>
|
||
<div class="warn">Дополнительно к сумме покупки кошелек платит сеть за создание записи тикета (обычно около 0.002 SOL).</div>
|
||
<div id="buyResult" class="muted"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
|
||
<script>
|
||
const PROGRAM_ID = new solanaWeb3.PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
|
||
const RPC_URL = "https://api.devnet.solana.com";
|
||
const ORACLE_ACCOUNT = new solanaWeb3.PublicKey("7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE");
|
||
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||
|
||
const SEEDS = {
|
||
config: "shine_payments_v3_config",
|
||
coef: "shine_payments_v3_coef_limit",
|
||
queues: "shine_payments_v3_queues",
|
||
ticketQ1: "shine_payments_v3_q1_ticket",
|
||
};
|
||
|
||
const COEF_SCALE = 1_000_000n;
|
||
const LAMPORTS_PER_SOL = 1_000_000_000n;
|
||
let walletPubkey = null;
|
||
let lastState = null;
|
||
let activeEdit = "usd";
|
||
|
||
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
|
||
|
||
function utf8(s) { return new TextEncoder().encode(s); }
|
||
function u64ToBytes(v) {
|
||
let x = BigInt(v);
|
||
const out = new Uint8Array(8);
|
||
for (let i = 0; i < 8; i++) { out[i] = Number(x & 255n); x >>= 8n; }
|
||
return out;
|
||
}
|
||
function readU64(data, offset) {
|
||
let x = 0n;
|
||
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
|
||
return x;
|
||
}
|
||
function readI32(data, offset) {
|
||
let x = Number(readU64(data, offset) & 0xffffffffn);
|
||
if (x > 0x7fffffff) x -= 0x100000000;
|
||
return x;
|
||
}
|
||
function readI64(data, offset) {
|
||
let x = readU64(data, offset);
|
||
if (x > 0x7fffffffffffffffn) x -= 0x10000000000000000n;
|
||
return x;
|
||
}
|
||
function concat(...parts) {
|
||
const len = parts.reduce((n, p) => n + p.length, 0);
|
||
const out = new Uint8Array(len);
|
||
let o = 0;
|
||
for (const p of parts) { out.set(p, o); o += p.length; }
|
||
return out;
|
||
}
|
||
function trimZeros(v) {
|
||
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
|
||
}
|
||
function lamportsToSolStr(l) {
|
||
return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
|
||
}
|
||
function centsToUsdStr(cents) {
|
||
return trimZeros((Number(cents) / 100).toFixed(2));
|
||
}
|
||
function usdTextToCents(text) {
|
||
const v = Number(text.trim().replace(",", "."));
|
||
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма USD");
|
||
return BigInt(Math.round(v * 100));
|
||
}
|
||
function solTextToLamports(text) {
|
||
const v = Number(text.trim().replace(",", "."));
|
||
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL");
|
||
return BigInt(Math.round(v * 1_000_000_000));
|
||
}
|
||
function parsePythPriceUpdateV2(data) {
|
||
const price = readI64(data, 74);
|
||
const exponent = readI32(data, 90);
|
||
const publishTime = readI64(data, 94);
|
||
if (price <= 0n) throw new Error("Оракул вернул некорректную цену");
|
||
let num = price * 100n;
|
||
let den = 1n;
|
||
if (exponent >= 0) {
|
||
num *= 10n ** BigInt(exponent);
|
||
} else {
|
||
den *= 10n ** BigInt(-exponent);
|
||
}
|
||
return { num, den, publishTime };
|
||
}
|
||
function lamportsToUsdCentsFloor(lamports, px) {
|
||
return (lamports * px.num) / (LAMPORTS_PER_SOL * px.den);
|
||
}
|
||
function usdCentsToLamportsCeil(usdCents, px) {
|
||
const n = usdCents * LAMPORTS_PER_SOL * px.den;
|
||
return (n + px.num - 1n) / px.num;
|
||
}
|
||
function applySlippageUp(lamports, pct) {
|
||
const bp = BigInt(Math.round(pct * 100));
|
||
return (lamports * (10_000n + bp) + 9_999n) / 10_000n;
|
||
}
|
||
function applySlippageDown(cents, pct) {
|
||
const bp = BigInt(Math.round(pct * 100));
|
||
return (cents * (10_000n - bp)) / 10_000n;
|
||
}
|
||
|
||
async function ixDiscriminator(name) {
|
||
const msg = utf8("global:" + name);
|
||
const hash = await crypto.subtle.digest("SHA-256", msg);
|
||
return new Uint8Array(hash).slice(0, 8);
|
||
}
|
||
|
||
function parseConfig(data) {
|
||
let o = 0;
|
||
const version = data[o++];
|
||
const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32;
|
||
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32;
|
||
const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32;
|
||
return { version, dao, manager, inflow };
|
||
}
|
||
function parseCoef(data) {
|
||
let o = 0;
|
||
const version = data[o++];
|
||
const coefPpm = readU64(data, o); o += 8;
|
||
const limitUsdCents = readU64(data, o); o += 8;
|
||
const reward = readU64(data, o); o += 8;
|
||
return { version, coefPpm, limitUsdCents, reward };
|
||
}
|
||
function parseQueues(data) {
|
||
let o = 0;
|
||
const version = data[o++];
|
||
const q1Total = readU64(data, o); o += 8;
|
||
const q1Paid = readU64(data, o); o += 8;
|
||
const q1SumTotal = readU64(data, o); o += 8;
|
||
const q1SumPaid = readU64(data, o); o += 8;
|
||
const q2Total = readU64(data, o); o += 8;
|
||
const q2Paid = readU64(data, o); o += 8;
|
||
const q2SumTotal = readU64(data, o); o += 8;
|
||
const q2SumPaid = readU64(data, o); o += 8;
|
||
return { version, q1Total, q1Paid, q1SumTotal, q1SumPaid, q2Total, q2Paid, q2SumTotal, q2SumPaid };
|
||
}
|
||
|
||
function getProvider() {
|
||
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
|
||
return window.solana;
|
||
}
|
||
|
||
async function connectWallet() {
|
||
const provider = getProvider();
|
||
const r = await provider.connect();
|
||
walletPubkey = new solanaWeb3.PublicKey(r.publicKey.toString());
|
||
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
|
||
await refreshState();
|
||
}
|
||
|
||
async function sendInstruction(ix) {
|
||
const provider = getProvider();
|
||
if (!walletPubkey) await connectWallet();
|
||
const tx = new solanaWeb3.Transaction().add(ix);
|
||
tx.feePayer = walletPubkey;
|
||
const bh = await connection.getLatestBlockhash("confirmed");
|
||
tx.recentBlockhash = bh.blockhash;
|
||
const signed = await provider.signTransaction(tx);
|
||
const sig = await connection.sendRawTransaction(signed.serialize(), { skipPreflight: false });
|
||
await connection.confirmTransaction({ signature: sig, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight }, "confirmed");
|
||
return sig;
|
||
}
|
||
|
||
function derivePdas() {
|
||
const [configPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.config)], PROGRAM_ID);
|
||
const [coefPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.coef)], PROGRAM_ID);
|
||
const [queuesPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID);
|
||
return { configPda, coefPda, queuesPda };
|
||
}
|
||
|
||
async function loadCoreState() {
|
||
const pdas = derivePdas();
|
||
const [cfgAi, coefAi, queuesAi, oracleAi] = await Promise.all([
|
||
connection.getAccountInfo(pdas.configPda, "confirmed"),
|
||
connection.getAccountInfo(pdas.coefPda, "confirmed"),
|
||
connection.getAccountInfo(pdas.queuesPda, "confirmed"),
|
||
connection.getAccountInfo(ORACLE_ACCOUNT, "confirmed"),
|
||
]);
|
||
if (!cfgAi || !coefAi || !queuesAi) throw new Error("PDA не инициализированы. Сначала выполните init на странице админки.");
|
||
if (!oracleAi) throw new Error("SOL/USD oracle account не найден");
|
||
const config = parseConfig(cfgAi.data);
|
||
const coef = parseCoef(coefAi.data);
|
||
const queues = parseQueues(queuesAi.data);
|
||
const pyth = parsePythPriceUpdateV2(oracleAi.data);
|
||
return { pdas, config, coef, queues, pyth };
|
||
}
|
||
|
||
function renderQuote() {
|
||
const el = document.getElementById("quoteInfo");
|
||
if (!lastState) { el.textContent = ""; return; }
|
||
try {
|
||
const slippage = Math.max(0, Number(document.getElementById("slippagePct").value.trim() || "0"));
|
||
const usdCents = usdTextToCents(document.getElementById("amountUsd").value);
|
||
const lamports = solTextToLamports(document.getElementById("amountSol").value);
|
||
const payForUsd = usdCentsToLamportsCeil(usdCents, lastState.pyth);
|
||
const usdForSol = lamportsToUsdCentsFloor(lamports, lastState.pyth);
|
||
const maxLamports = applySlippageUp(payForUsd, slippage);
|
||
const minUsd = applySlippageDown(usdForSol, slippage);
|
||
el.innerHTML = `
|
||
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(lastState.pyth.num) / Number(lastState.pyth.den) / 100).toFixed(6))}</b></div>
|
||
<div>Возраст цены: <b>${Math.max(0, Math.floor(Date.now()/1000 - Number(lastState.pyth.publishTime)))} сек</b></div>
|
||
<div>Если покупка по USD: к списанию примерно <b>${lamportsToSolStr(payForUsd)} SOL</b>, с допуском максимум <b>${lamportsToSolStr(maxLamports)} SOL</b>.</div>
|
||
<div>Если покупка по SOL: это примерно <b>${centsToUsdStr(usdForSol)} USD</b>, с допуском минимум <b>${centsToUsdStr(minUsd)} USD</b>.</div>
|
||
`;
|
||
} catch (e) {
|
||
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||
}
|
||
}
|
||
|
||
function syncFromUsd() {
|
||
if (!lastState) return;
|
||
try {
|
||
const usdCents = usdTextToCents(document.getElementById("amountUsd").value);
|
||
const lamports = usdCentsToLamportsCeil(usdCents, lastState.pyth);
|
||
document.getElementById("amountSol").value = lamportsToSolStr(lamports);
|
||
} catch (_) {}
|
||
renderQuote();
|
||
}
|
||
function syncFromSol() {
|
||
if (!lastState) return;
|
||
try {
|
||
const lamports = solTextToLamports(document.getElementById("amountSol").value);
|
||
const usdCents = lamportsToUsdCentsFloor(lamports, lastState.pyth);
|
||
document.getElementById("amountUsd").value = centsToUsdStr(usdCents);
|
||
} catch (_) {}
|
||
renderQuote();
|
||
}
|
||
|
||
async function refreshState() {
|
||
const el = document.getElementById("stateInfo");
|
||
try {
|
||
lastState = await loadCoreState();
|
||
const { config, coef, queues, pyth } = lastState;
|
||
const coefText = trimZeros((Number(coef.coefPpm) / Number(COEF_SCALE)).toFixed(6));
|
||
const pendingBeforeCount = queues.q1Total - queues.q1Paid;
|
||
const pendingBeforeSum = queues.q1SumTotal - queues.q1SumPaid;
|
||
const nextTicketIndex = queues.q1Total + 1n;
|
||
const remainingByTotal = coef.limitUsdCents > queues.q1SumTotal ? (coef.limitUsdCents - queues.q1SumTotal) : 0n;
|
||
const paused = queues.q1SumTotal >= coef.limitUsdCents;
|
||
el.innerHTML = `
|
||
<div>DAO: <code>${config.dao}</code></div>
|
||
<div>Inflow vault: <code>${config.inflow}</code></div>
|
||
<div class="muted">Тестовый DAO-кошелек. В production будет реальный адрес DAO.</div>
|
||
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(pyth.num) / Number(pyth.den) / 100).toFixed(6))}</b></div>
|
||
<div>Коэффициент: <b>${coefText}</b></div>
|
||
<div>Лимит очереди 1: <b>${centsToUsdStr(coef.limitUsdCents)} USD</b></div>
|
||
<div>Награда за шаг выплат: <b>${lamportsToSolStr(coef.reward)} SOL</b></div>
|
||
<div>Из них сейчас не выплачено билетов: <b>${pendingBeforeCount.toString()}</b></div>
|
||
<div>Из них сейчас не выплачено по сумме: <b>${centsToUsdStr(pendingBeforeSum)} USD</b></div>
|
||
<div>Ваш билет будет номером: <b>${nextTicketIndex.toString()}</b></div>
|
||
<div>Осталось лимита до паузы: <b>${centsToUsdStr(remainingByTotal)} USD</b></div>
|
||
<div class="${paused ? "warn" : "ok"}">${paused ? "Покупка временно приостановлена: лимит заполнен." : "Покупка доступна."}</div>
|
||
`;
|
||
if (activeEdit === "usd") syncFromUsd();
|
||
else syncFromSol();
|
||
} catch (e) {
|
||
el.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function buyByUsd() {
|
||
const out = document.getElementById("buyResult");
|
||
out.textContent = "";
|
||
try {
|
||
const provider = getProvider();
|
||
if (!walletPubkey) await connectWallet();
|
||
else if (!provider.isConnected) await provider.connect();
|
||
const { pdas, config, queues, coef, pyth } = lastState || await loadCoreState();
|
||
if (queues.q1SumTotal >= coef.limitUsdCents) throw new Error("Покупка временно приостановлена: лимит заполнен.");
|
||
|
||
const usdCents = usdTextToCents(document.getElementById("amountUsd").value);
|
||
const slippage = Math.max(0, Number(document.getElementById("slippagePct").value.trim() || "0"));
|
||
const payLamports = usdCentsToLamportsCeil(usdCents, pyth);
|
||
const maxPayLamports = applySlippageUp(payLamports, slippage);
|
||
const recipientRaw = document.getElementById("recipient").value.trim();
|
||
const recipient = recipientRaw ? new solanaWeb3.PublicKey(recipientRaw) : walletPubkey;
|
||
|
||
const nextIndex = queues.q1Total + 1n;
|
||
const [ticketPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.ticketQ1), u64ToBytes(nextIndex)], PROGRAM_ID);
|
||
const disc = await ixDiscriminator("buy_ticket_usd");
|
||
const data = concat(disc, u64ToBytes(usdCents), u64ToBytes(maxPayLamports), recipient.toBytes());
|
||
const keys = [
|
||
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||
{ pubkey: pdas.configPda, isSigner: false, isWritable: true },
|
||
{ pubkey: pdas.coefPda, isSigner: false, isWritable: true },
|
||
{ pubkey: pdas.queuesPda, isSigner: false, isWritable: true },
|
||
{ pubkey: ticketPda, isSigner: false, isWritable: true },
|
||
{ pubkey: new solanaWeb3.PublicKey(config.dao), isSigner: false, isWritable: true },
|
||
{ pubkey: ORACLE_ACCOUNT, isSigner: false, isWritable: false },
|
||
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
|
||
];
|
||
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
|
||
const sig = await sendInstruction(ix);
|
||
out.innerHTML = `<span class="ok">Готово. Tx: <code>${sig}</code></span>`;
|
||
await refreshState();
|
||
} catch (e) {
|
||
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||
}
|
||
}
|
||
|
||
async function buyBySol() {
|
||
const out = document.getElementById("buyResult");
|
||
out.textContent = "";
|
||
try {
|
||
const provider = getProvider();
|
||
if (!walletPubkey) await connectWallet();
|
||
else if (!provider.isConnected) await provider.connect();
|
||
const { pdas, config, queues, coef, pyth } = lastState || await loadCoreState();
|
||
if (queues.q1SumTotal >= coef.limitUsdCents) throw new Error("Покупка временно приостановлена: лимит заполнен.");
|
||
|
||
const lamports = solTextToLamports(document.getElementById("amountSol").value);
|
||
const slippage = Math.max(0, Number(document.getElementById("slippagePct").value.trim() || "0"));
|
||
const usdCents = lamportsToUsdCentsFloor(lamports, pyth);
|
||
const minUsdCents = applySlippageDown(usdCents, slippage);
|
||
const recipientRaw = document.getElementById("recipient").value.trim();
|
||
const recipient = recipientRaw ? new solanaWeb3.PublicKey(recipientRaw) : walletPubkey;
|
||
|
||
const nextIndex = queues.q1Total + 1n;
|
||
const [ticketPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.ticketQ1), u64ToBytes(nextIndex)], PROGRAM_ID);
|
||
const disc = await ixDiscriminator("buy_ticket_sol");
|
||
const data = concat(disc, u64ToBytes(lamports), u64ToBytes(minUsdCents), recipient.toBytes());
|
||
const keys = [
|
||
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||
{ pubkey: pdas.configPda, isSigner: false, isWritable: true },
|
||
{ pubkey: pdas.coefPda, isSigner: false, isWritable: true },
|
||
{ pubkey: pdas.queuesPda, isSigner: false, isWritable: true },
|
||
{ pubkey: ticketPda, isSigner: false, isWritable: true },
|
||
{ pubkey: new solanaWeb3.PublicKey(config.dao), isSigner: false, isWritable: true },
|
||
{ pubkey: ORACLE_ACCOUNT, isSigner: false, isWritable: false },
|
||
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
|
||
];
|
||
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
|
||
const sig = await sendInstruction(ix);
|
||
out.innerHTML = `<span class="ok">Готово. Tx: <code>${sig}</code></span>`;
|
||
await refreshState();
|
||
} catch (e) {
|
||
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||
}
|
||
}
|
||
|
||
document.getElementById("connectBtn").addEventListener("click", connectWallet);
|
||
document.getElementById("refreshBtn").addEventListener("click", refreshState);
|
||
document.getElementById("buyUsdBtn").addEventListener("click", buyByUsd);
|
||
document.getElementById("buySolBtn").addEventListener("click", buyBySol);
|
||
document.getElementById("amountUsd").addEventListener("input", () => { activeEdit = "usd"; syncFromUsd(); });
|
||
document.getElementById("amountSol").addEventListener("input", () => { activeEdit = "sol"; syncFromSol(); });
|
||
document.getElementById("slippagePct").addEventListener("input", renderQuote);
|
||
refreshState();
|
||
</script>
|
||
</body>
|
||
</html>
|