diff --git a/configs/shared/goa-stats-snippet.html b/configs/shared/goa-stats-snippet.html
new file mode 100644
index 0000000..7070aee
--- /dev/null
+++ b/configs/shared/goa-stats-snippet.html
@@ -0,0 +1,39 @@
+
+
+ GOA flow
+
+
+ Accounts
+ —
+
+
+ Bank credits
+ —
+
+
+ Withdraws
+ —
+
+
+ Unique wallets
+ —
+
+
+ 24h
+ —
+
+
+ 7d
+ —
+
+
+
+
+
diff --git a/configs/shared/goa-stats.css b/configs/shared/goa-stats.css
new file mode 100644
index 0000000..9f3f45f
--- /dev/null
+++ b/configs/shared/goa-stats.css
@@ -0,0 +1,86 @@
+/* Shared GOA flow stats (used on bank / exchange / merchant landings) */
+.stats {
+ background: rgba(18, 28, 28, 0.55);
+ border: 1px solid rgba(62, 207, 191, 0.28);
+ border-radius: 16px;
+ padding: 1.1rem 1.15rem 1.2rem;
+ margin: 0 0 1.5rem;
+}
+.stats h2 {
+ margin: 0 0 0.85rem;
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--muted, #c9b8a0);
+ text-align: center;
+}
+.stats-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.65rem 0.85rem;
+}
+@media (min-width: 420px) {
+ .stats-grid { grid-template-columns: 1fr 1fr 1fr; }
+}
+.stat {
+ background: rgba(0, 0, 0, 0.22);
+ border-radius: 10px;
+ padding: 0.55rem 0.65rem;
+ min-width: 0;
+}
+.stat .k {
+ display: block;
+ font-size: 0.68rem;
+ color: var(--muted, #c9b8a0);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ margin-bottom: 0.2rem;
+ text-align: center;
+}
+.stat .v {
+ display: block;
+ font-size: 1.05rem;
+ font-weight: 750;
+ color: #f0d090;
+ word-break: break-word;
+ font-variant-numeric: tabular-nums;
+ text-align: center;
+}
+.stat .v.teal { color: #5eead4; }
+.stat.wide { grid-column: 1 / -1; }
+.wd-list {
+ list-style: none;
+ margin: 0.15rem 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+.wd-list li {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 0.25rem 0.75rem;
+ font-size: 0.88rem;
+ font-weight: 650;
+ color: #f0d090;
+ font-variant-numeric: tabular-nums;
+ border-bottom: 1px solid rgba(201, 184, 160, 0.12);
+ padding-bottom: 0.3rem;
+}
+.wd-list li:last-child { border-bottom: none; padding-bottom: 0; }
+.wd-list .amt { color: #5eead4; font-weight: 750; }
+.wd-list .meta {
+ color: var(--muted, #c9b8a0);
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+.stats-foot {
+ margin: 0.75rem 0 0;
+ font-size: 0.72rem;
+ color: var(--muted, #c9b8a0);
+ text-align: center;
+}
+.stats-foot a { color: #e8c878; }
+.stats-foot.err { color: #f0a090; }
diff --git a/configs/shared/goa-stats.js b/configs/shared/goa-stats.js
new file mode 100644
index 0000000..8689216
--- /dev/null
+++ b/configs/shared/goa-stats.js
@@ -0,0 +1,130 @@
+/* Load GOA flow stats from bank landing stats.json (CORS on bank). */
+(function () {
+ var STATS_URL = "https://bank.hacktivism.ch/intro/stats.json";
+ var BANK_INTRO = "https://bank.hacktivism.ch/intro/";
+
+ function fmtCest(unix, fallback) {
+ var base = "";
+ if (fallback) {
+ base = String(fallback).replace(/\s*(CEST|CET|UTC|Z)\s*$/i, "").trim();
+ } else if (unix != null && unix !== "") {
+ try {
+ base = new Intl.DateTimeFormat("de-CH", {
+ timeZone: "Europe/Zurich",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false
+ }).format(new Date(Number(unix) * 1000));
+ } catch (e) {
+ base = "";
+ }
+ }
+ return base ? base + " Europe/Zurich" : "";
+ }
+
+ function set(id, v) {
+ var el = document.getElementById(id);
+ if (el) el.textContent = v == null || v === "" ? "—" : String(v);
+ }
+
+ function showFallback(msg) {
+ var box = document.getElementById("stats");
+ var foot = document.getElementById("st-foot");
+ if (box) box.hidden = false;
+ if (foot) {
+ foot.hidden = false;
+ foot.className = "stats-foot err";
+ foot.innerHTML =
+ (msg || "Stats offline") +
+ ' · bank.hacktivism.ch';
+ }
+ }
+
+ function loadStats() {
+ var box = document.getElementById("stats");
+ var foot = document.getElementById("st-foot");
+ if (!box) return;
+ box.hidden = false;
+
+ // Same-origin first (bank landing), else public bank URL
+ var urls = [];
+ if (location.hostname.indexOf("bank.hacktivism") !== -1) {
+ urls.push("stats.json");
+ }
+ urls.push(STATS_URL);
+
+ function tryNext(i) {
+ if (i >= urls.length) {
+ showFallback("No stats");
+ return;
+ }
+ fetch(urls[i], { cache: "no-store", mode: "cors" })
+ .then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ })
+ .then(function (d) {
+ if (!d || !d.ok) throw new Error("bad stats");
+ var w = d.withdraws || {};
+ var h24 = w.last_24h || {};
+ var h7 = w.last_7d || {};
+ var ba = d.bank_accounts || {};
+ var wl = d.wallets || {};
+ var flow = d.flow || {};
+ var fin = flow.incoming || {};
+ var fwd = flow.withdraw || {};
+ set("st-accounts", ba.total != null ? ba.total : "—");
+ set("st-wallets", wl.unique_reserves != null ? wl.unique_reserves : "—");
+ set("st-incoming", fin.amount || flow.total_in || "—");
+ set("st-withdraw", fwd.amount || w.total_amount || "—");
+ set("st-24h", h24.amount || "GOA:0");
+ set("st-7d", h7.amount || "GOA:0");
+
+ var list = document.getElementById("st-recent");
+ if (list) {
+ list.innerHTML = "";
+ var rows = (d.recent_withdraws || []).slice(0, 3);
+ if (!rows.length) {
+ var empty = document.createElement("li");
+ empty.className = "meta";
+ empty.textContent = "—";
+ list.appendChild(empty);
+ } else {
+ rows.forEach(function (row) {
+ var li = document.createElement("li");
+ var amt = document.createElement("span");
+ amt.className = "amt";
+ amt.textContent = row.amount || "?";
+ var meta = document.createElement("span");
+ meta.className = "meta";
+ meta.textContent = fmtCest(row.at_unix, row.at || row.at_iso) || "";
+ li.appendChild(amt);
+ li.appendChild(meta);
+ list.appendChild(li);
+ });
+ }
+ }
+ if (foot) {
+ foot.hidden = false;
+ foot.className = "stats-foot";
+ foot.innerHTML =
+ 'Source: bank.hacktivism.ch' +
+ (d.generated_at_human ? " · " + d.generated_at_human : "");
+ }
+ })
+ .catch(function () {
+ tryNext(i + 1);
+ });
+ }
+ tryNext(0);
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", loadStats);
+ } else {
+ loadStats();
+ }
+})();