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: "",