Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions static/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ <h1>StellarGate</h1>
<button class="chip" data-status="underpaid" data-label="Underpaid" aria-pressed="false">Underpaid</button>
<button class="chip" data-status="expired" data-label="Expired" aria-pressed="false">Expired</button>
</div>
<!-- Asset filter: narrows the list to payments for one asset code.
"All assets" (empty value) is the default. The options match the
gateway's default ACCEPTED_ASSETS (XLM and USDC). -->
<label class="small" for="asset-filter">Asset</label>
<select id="asset-filter">
<option value="">All assets</option>
<option value="XLM">XLM</option>
<option value="USDC">USDC</option>
</select>
<div class="filters" role="group" aria-label="Filter by creation date">
<label class="small" for="created-after">From</label>
<input id="created-after" type="date" />
Expand Down
13 changes: 12 additions & 1 deletion static/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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")));
Expand Down
183 changes: 145 additions & 38 deletions static/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
*/
Expand All @@ -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+$/, "")
Expand All @@ -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));
Expand All @@ -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();
}
50 changes: 50 additions & 0 deletions static/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,6 +75,11 @@ export function storeKey(key) {
storedKeyExpiresAt = Date.now() + KEY_TTL_MS;
}

function forgetKey() {
storedKey = null;
storedKeyExpiresAt = 0;
}

/**
* Build a session store.
*
Expand Down
2 changes: 2 additions & 0 deletions static/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down
Loading