diff --git a/static/dashboard.html b/static/dashboard.html index 93eb826..6fd854d 100644 --- a/static/dashboard.html +++ b/static/dashboard.html @@ -129,6 +129,15 @@

StellarGate

+ + +
diff --git a/static/dashboard.js b/static/dashboard.js index 87ef63c..10e86aa 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -245,7 +245,7 @@ import { matchShortcut, moveRow, SHORTCUTS } from "./keys.js"; var key = store.get().key; if (key) headers.Authorization = "Bearer " + key; - return fetch(API_BASE + path, { method: opts.method || "GET", headers: headers, body: opts.body || undefined }).then( + return fetch(API_BASE + path, { method: opts.method || "GET", headers: headers, body: opts.body || null }).then( function (res) { if (res.status === 401) { signOut("That API key was rejected. Please sign in again."); @@ -1131,6 +1131,9 @@ import { matchShortcut, moveRow, SHORTCUTS } from "./keys.js"; var auto = $("auto-refresh"); if (auto) auto.checked = state.autoRefresh; + var assetEl = $("asset-filter"); + if (assetEl && assetEl.value !== (state.asset || "")) assetEl.value = state.asset || ""; + syncPresetUi(); } @@ -1287,6 +1290,14 @@ import { matchShortcut, moveRow, SHORTCUTS } from "./keys.js"; onFilterChange(); }); + var assetFilter = $("asset-filter"); + if (assetFilter) { + assetFilter.addEventListener("change", function () { + store.update({ asset: assetFilter.value }); + onFilterChange(); + }); + } + Array.prototype.forEach.call(document.querySelectorAll(".preset"), function (btn) { btn.addEventListener("click", function () { var range = presetRange(Number(btn.getAttribute("data-days"))); diff --git a/static/format.js b/static/format.js index 7d41eb1..3c67868 100644 --- a/static/format.js +++ b/static/format.js @@ -6,6 +6,15 @@ * `dashboard.js` imports these helpers rather than defining them inline. */ +/** Placeholder for absent/empty values. */ +export var EMPTY = "—"; + +/** Base URL for the Stellar Expert transaction explorer. */ +export var EXPLORER_TX_BASE = "https://stellar.expert/explorer/public/tx/"; + +/** Column headers for the CSV export (#706). */ +export var CSV_COLUMNS = ["id", "status", "amount", "asset", "memo", "created_at"]; + /** Map a payment or delivery status onto a pill style. */ export function pillClass(status) { switch (status) { @@ -25,67 +34,110 @@ export function pillClass(status) { /** Format a payment amount with its asset code. */ export function formatAmount(amount, asset) { - if (!amount) return "—"; - return amount + " " + (asset || "XLM"); + // Only skip truly absent values (null / empty string); undefined and + // non-numeric strings must pass through so the column never silently hides + // data or renders "NaN XLM". + if (amount === null || amount === "") return EMPTY; + // Strip trailing zeros from a numeric amount string, preserving up to + // 7 decimal places (one stroop). Non-numeric values pass through intact. + var str = String(amount); + var n = Number(str); + if (isFinite(n)) { + // toFixed(7) then trim trailing zeros and a possible trailing dot. + str = n.toFixed(7).replace(/\.?0+$/, ""); + // Guard against exponent form for very large/small numbers. + if (str.indexOf("e") !== -1) str = String(amount); + } + return str + " " + (asset || "XLM"); } /** Return a Stellar expert explorer URL for a transaction hash. */ export function explorerTx(hash) { - return "https://stellar.expert/explorer/public/tx/" + encodeURIComponent(hash); + return EXPLORER_TX_BASE + encodeURIComponent(hash); } -/** Human-readable relative time (e.g. "2 min ago", "just now"). */ -export function relativeTime(iso) { - if (!iso) return "—"; +/** + * Human-readable relative time (e.g. "5s ago", "5m ago", "2h ago", "2d ago"). + * + * Accepts an optional `now` epoch-ms override so tests can drive the clock. + * Returns "never" for an absent or empty value, and echoes an unparseable + * string as-is rather than printing "Invalid Date". + * + * @param {string|null|undefined} iso + * @param {number} [now] + */ +export function relativeTime(iso, now) { + if (!iso) return "never"; var d = new Date(iso); if (isNaN(d.getTime())) return iso; - var diffMs = Date.now() - d.getTime(); - var diffSec = Math.round(diffMs / 1000); - if (diffSec < 5) return "just now"; + var diffMs = (now !== undefined ? now : Date.now()) - d.getTime(); + var diffSec = Math.floor(diffMs / 1000); if (diffSec < 60) return diffSec + "s ago"; - var diffMin = Math.round(diffSec / 60); - if (diffMin < 60) return diffMin + " min ago"; - var diffHr = Math.round(diffMin / 60); + var diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return diffMin + "m ago"; + var diffHr = Math.floor(diffMin / 60); if (diffHr < 24) return diffHr + "h ago"; - return Math.round(diffHr / 24) + "d ago"; + return Math.floor(diffHr / 24) + "d ago"; } -/** Human-readable countdown to an ISO timestamp (e.g. "5m 32s"). */ -export function countdown(iso) { +/** + * Human-readable countdown to an ISO timestamp. + * + * Accepts an optional `now` epoch-ms override so tests can drive the clock. + * Floors within each bucket (never overstates time left). + * Clamps to "0s" when the instant is in the past. + * Returns "" for an unparseable instant. + * + * @param {string|null|undefined} iso + * @param {number} [now] + */ +export function countdown(iso, now) { if (!iso) return ""; var d = new Date(iso); - if (isNaN(d.getTime())) return ""; - var diffMs = d.getTime() - Date.now(); - if (diffMs <= 0) return "expired"; + var nowMs = now !== undefined ? now : Date.now(); + if (isNaN(d.getTime()) || isNaN(nowMs)) return ""; + var diffMs = d.getTime() - nowMs; + if (diffMs <= 0) return "0s"; var totalSec = Math.floor(diffMs / 1000); - var h = Math.floor(totalSec / 3600); + var days = Math.floor(totalSec / 86400); + var h = Math.floor((totalSec % 86400) / 3600); var m = Math.floor((totalSec % 3600) / 60); var s = totalSec % 60; - if (h > 0) return h + "h " + m + "m"; - if (m > 0) return m + "m " + s + "s"; + if (days > 0) return days + "d"; + if (h > 0) return h + "h"; + if (m > 0) return m + "m"; return s + "s"; } /** Format an ISO timestamp for display, falling back to the raw value. */ export function fmtTime(iso) { - if (!iso) return "—"; + if (!iso) return EMPTY; var d = new Date(iso); if (isNaN(d.getTime())) return iso; return d.toLocaleString(); } +/** + * Shorten a long identifier with an ellipsis. Values ≤ 12 characters pass + * through unchanged; longer ones are clipped to 8 characters followed by "…". + * + * Non-string values pass through untouched (null, undefined, numbers). + * + * @param {*} id + * @returns {*} + */ +export function shortId(id) { + if (typeof id !== "string") return id; + if (id.length <= 12) return id; + return id.slice(0, 8) + "..."; +} + /** * Build a SEP-7 `web+stellar:pay` URI for a payment intent. * * The URI encodes the destination, memo, amount and asset so a SEP-7-capable * wallet can prefill a payment directly from a QR code scan or a tap. * - * Native XLM uses no asset_code/asset_issuer parameters; issued assets use - * both so the wallet can verify the exact token. All values are percent-encoded - * so memo text containing spaces, commas or special characters round-trips - * correctly. Memo type is always MEMO_TEXT to match how the gateway generates - * and matches memos (see `generate_unique_memo` in the backend). - * * @param {object} payment A payment record from the API. * @returns {string} The full web+stellar:pay URI. */ @@ -96,8 +148,6 @@ export function buildSep7Uri(payment) { params.push("destination=" + encodeURIComponent(p.destination_address)); } if (p.amount) { - // Strip trailing zeros the same way formatAmount does, so "10.0000000" - // becomes "10" in the URI — wallets display this value to the user. var n = Number(p.amount); var amtStr = isFinite(n) ? n.toFixed(7).replace(/\.?0+$/, "") @@ -106,7 +156,6 @@ export function buildSep7Uri(payment) { } var asset = String(p.asset || "XLM").toUpperCase(); if (asset !== "XLM") { - // Issued asset: asset_code + asset_issuer required. params.push("asset_code=" + encodeURIComponent(asset)); if (p.asset_issuer) { params.push("asset_issuer=" + encodeURIComponent(p.asset_issuer)); @@ -124,19 +173,77 @@ export function buildSep7Uri(payment) { * the memo and the payment id. * * This is deliberately client-side: it narrows what the operator can currently - * see without another round trip, and it is what the `/` shortcut focuses. + * see without another round trip. */ export function filterPayments(payments, query) { var q = String(query || "").trim().toLowerCase(); if (!q) return payments || []; return (payments || []).filter(function (p) { + if (!p) return false; return ( - String(p && p.memo ? p.memo : "") - .toLowerCase() - .indexOf(q) >= 0 || - String(p && p.id ? p.id : "") - .toLowerCase() - .indexOf(q) >= 0 + String(p.memo || "").toLowerCase().indexOf(q) >= 0 || + String(p.id || "").toLowerCase().indexOf(q) >= 0 ); }); } + +/* ── CSV export (#706) ──────────────────────────────────────────────────── */ + +/** + * Quote a single CSV field: wrap in double-quotes and escape embedded + * double-quotes by doubling them. A missing value becomes an empty quoted field. + * + * @param {*} value + * @returns {string} + */ +export function csvField(value) { + var str = (value === undefined || value === null) ? "" : String(value); + return '"' + str.replace(/"/g, '""') + '"'; +} + +/** + * Serialise an array of payment objects to a CSV string with a header row. + * Each column is defined by a key in `columns`; the values are taken from the + * matching property of each row. + * + * @param {object[]} payments + * @param {string[]} [columns] Defaults to CSV_COLUMNS. + * @returns {string} + */ +export function toCsv(payments, columns) { + var cols = columns || CSV_COLUMNS; + var header = cols.map(function (c) { return csvField(c); }).join(","); + var rows = (payments || []).map(function (p) { + return cols.map(function (col) { return csvField(p ? p[col] : undefined); }).join(","); + }); + return [header].concat(rows).join("\n"); +} + +/* ── List query builder ─────────────────────────────────────────────────── */ + +/** + * Build the `/payments?…` query string from the current view state. + * + * The API base (/v1) is intentionally omitted — it is defined once in + * dashboard.js so requests cannot gain an accidental double prefix. No + * credential ever appears here: the key is always sent in the + * `Authorization` header, never as a query parameter. + * + * @param {object} state Partial view-state object from the store. + * @returns {string} Path + query string, e.g. "/payments?limit=25&status=pending". + */ +export function buildListQuery(state) { + var s = state || {}; + var params = new URLSearchParams(); + params.set("limit", String(s.pageSize || 25)); + + if (s.status) params.set("status", s.status); + if (s.asset) params.set("asset", s.asset); + if (s.cursor) params.set("cursor", s.cursor); + + // Date pickers give back "YYYY-MM-DD"; expand to full UTC day boundaries. + if (s.createdAfter) params.set("created_after", s.createdAfter + "T00:00:00Z"); + if (s.createdBefore) params.set("created_before", s.createdBefore + "T23:59:59Z"); + + return "/payments?" + params.toString(); +} diff --git a/static/session.js b/static/session.js index a15ebce..b2af8d1 100644 --- a/static/session.js +++ b/static/session.js @@ -6,6 +6,51 @@ // How long a stored key stays valid, in milliseconds. const KEY_TTL_MS = 30 * 60 * 1000; +/** The localStorage/sessionStorage key under which the API key is stored. */ +export var KEY_NAME = "stellargate.apiKey"; + +/** The localStorage/sessionStorage key under which the save timestamp is stored. */ +export var KEY_SAVED_AT = "stellargate.apiKeySavedAt"; + +/** How long the session-expiry hint window lasts, in milliseconds (30 min). */ +export var SESSION_TTL_MS = KEY_TTL_MS; + +/** + * A minimal in-memory Storage-like object for environments where the real + * Web Storage API is unavailable (Node.js tests, Safari private mode fallback). + */ +export function memoryStorage() { + var map = Object.create(null); + return { + getItem: function (k) { + return Object.prototype.hasOwnProperty.call(map, k) ? map[k] : null; + }, + setItem: function (k, v) { + map[k] = String(v); + }, + removeItem: function (k) { + delete map[k]; + }, + }; +} + +// ── Safe wrappers around Web Storage ───────────────────────────────────────── +// Safari private mode throws on any access; these keep the rest of the module +// throw-free. A return of null means "storage unavailable or key absent". + +function safeGet(area, key) { + try { return area ? area.getItem(key) : null; } catch (e) { return null; } +} + +function safeSet(area, key, value) { + try { if (area) { area.setItem(key, value); return true; } return false; } + catch (e) { return false; } +} + +function safeRemove(area, key) { + try { if (area) area.removeItem(key); } catch (e) { /* non-fatal */ } +} + // In-memory storage for the API key. Deliberately not persisted anywhere so // the key can never leak through URLs, logs or console output. let storedKey = null; @@ -30,6 +75,11 @@ export function storeKey(key) { storedKeyExpiresAt = Date.now() + KEY_TTL_MS; } +function forgetKey() { + storedKey = null; + storedKeyExpiresAt = 0; +} + /** * Build a session store. * diff --git a/static/state.js b/static/state.js index c880ae9..456de08 100644 --- a/static/state.js +++ b/static/state.js @@ -72,6 +72,8 @@ export function initialState() { return { key: null, status: "", + /** Asset code filter ("XLM", "USDC", …), or "" for all assets. */ + asset: "", search: "", pageSize: 25, createdAfter: "",