diff --git a/configs/merchant-landing/shop-pay.css b/configs/merchant-landing/shop-pay.css index 7694e4c..a3cf84a 100644 --- a/configs/merchant-landing/shop-pay.css +++ b/configs/merchant-landing/shop-pay.css @@ -1,4 +1,17 @@ -/* GOA shop pay popup — DD 90 inspired QR frame + logo */ +/* GOA shop pay popup — QR_Taler style from taler-merchant-webui / @gnu-taler/web-util */ + +/* Animated blue ring (same as webui QR.tsx) */ +@property --angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} +@keyframes goa-pay-qr-rotate { + to { + --angle: 360deg; + } +} + .shop-item[data-product] { cursor: pointer; transition: border-color 0.15s, transform 0.12s, background 0.15s; @@ -39,7 +52,7 @@ button.shop-item { border: 1px solid rgba(167, 139, 250, 0.45); border-radius: 16px; padding: 1.25rem 1.2rem 1.2rem; - max-width: 22rem; + max-width: 24rem; width: 100%; text-align: center; box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); @@ -70,49 +83,156 @@ button.shop-item { color: #c4b5fd; font-variant-numeric: tabular-nums; } -/* DD 90 frame */ -.goa-pay-frame { + +/* === QR_Taler frame (webui QR.tsx) === */ +.goa-pay-taler-qr { + width: 100%; + max-width: 280px; + margin: 0 auto 0.75rem; + padding: 10px; + border-radius: 20px; + box-sizing: border-box; position: relative; - width: 16rem; - max-width: 100%; - margin: 0 auto 0.65rem; - padding: 6.25%; /* (100-87.5)/2 */ - background: #f1f1f4; - border-radius: 8%; + background: conic-gradient( + from var(--angle), + #0042b3 0deg, + #f1f1f4 20deg, + #f1f1f4 150deg, + #f1f1f4 160deg, + #0042b3 180deg, + #f1f1f4 200deg, + #f1f1f4 330deg, + #f1f1f4 340deg, + #0042b3 + ); + animation: goa-pay-qr-rotate 10s linear infinite; +} +.goa-pay-taler-qr__inner { + padding: 10px; + border-radius: 20px; + background: #fff; + line-height: 0; + min-height: 180px; box-sizing: border-box; } -.goa-pay-qr { - background: #fff; - border-radius: 4px; - padding: 0; - line-height: 0; -} -.goa-pay-qr canvas, -.goa-pay-qr img, -.goa-pay-qr table { +/* Always an with explicit pixel size from JS */ +.goa-pay-taler-qr__inner img { display: block !important; - width: 100% !important; - height: auto !important; - margin: 0 auto; -} -.goa-pay-logo { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - width: 22%; - height: auto; + margin: 0 auto !important; + border: 0 !important; background: #fff; - padding: 2%; - border-radius: 4px; - pointer-events: none; - box-shadow: 0 0 0 2px #fff; + image-rendering: pixelated; } +/* Center logo plate — official qr-logo (webui uses ~100×50 on large QR) */ +.goa-pay-taler-qr__logo { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 28%; + height: auto; + max-width: 100px; + pointer-events: none; + background: transparent; +} + +/* Settlement: collapsed by default, expand on click */ +.goa-pay-settle { + margin-top: 0.85rem; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 12px; + overflow: hidden; + background: rgba(0, 0, 0, 0.22); +} +.goa-pay-settle-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + margin: 0; + padding: 0.65rem 0.75rem; + border: 0; + background: transparent; + color: #cbd5e1; + font: inherit; + font-size: 0.78rem; + font-weight: 650; + text-align: left; + cursor: pointer; + line-height: 1.3; +} +.goa-pay-settle-toggle:hover { + background: rgba(148, 163, 184, 0.1); + color: #e2e8f0; +} +.goa-pay-settle-toggle-text { + flex: 1; +} +.goa-pay-settle-chevron { + flex-shrink: 0; + transition: transform 0.15s ease; + color: #94a3b8; + font-size: 0.85rem; +} +.goa-pay-modal.settle-open .goa-pay-settle-chevron { + transform: rotate(90deg); +} +.goa-pay-settle-body { + padding: 0 0.75rem 0.75rem; + border-top: 1px solid rgba(148, 163, 184, 0.25); +} +.goa-pay-settle-note { + margin-top: 0.55rem; +} +/* Settlement payto QR — plain box (not wallet pay / no logo / no blue ring) */ +.goa-pay-payto-qr { + width: fit-content; + max-width: 160px; + margin: 0.35rem auto 0.55rem; + padding: 0.45rem 0.5rem 0.5rem; + border-radius: 10px; + background: #fff; + border: 1px dashed rgba(100, 116, 139, 0.75); + box-sizing: border-box; +} +.goa-pay-payto-qr__caption { + font-size: 0.62rem; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + text-align: center; + margin: 0 0 0.35rem; + line-height: 1.2; +} +.goa-pay-payto-qr__inner { + line-height: 0; + min-height: 0; +} +.goa-pay-payto-qr__inner img { + display: block !important; + margin: 0 auto !important; + background: #fff; + image-rendering: pixelated; +} +.goa-pay-sub { + margin: 0 0 0.55rem; + font-size: 0.75rem; + line-height: 1.35; + color: #b8a8c9; + font-weight: 500; + text-align: center; +} + .goa-pay-hint { margin: 0 0 0.75rem; font-size: 0.82rem; color: #b8a8c9; } +.goa-pay-hint.err { + color: #fca5a5; +} .goa-pay-cta { display: block; text-decoration: none; @@ -126,6 +246,10 @@ button.shop-item { .goa-pay-cta:hover { filter: brightness(1.06); } +.goa-pay-cta.disabled { + opacity: 0.45; + pointer-events: none; +} .goa-pay-label { margin: 0.55rem 0 0.2rem; font-size: 0.68rem; @@ -135,36 +259,27 @@ button.shop-item { color: #b8a8c9; } .goa-pay-line { - margin: 0; - text-align: left; + margin: 0 0 0.35rem; + text-align: center; + font-size: 0.88rem; + font-weight: 650; } -.goa-pay-uri { - display: inline; - font-size: 0.65rem; - word-break: break-all; - color: #e9d5ff; - background: rgba(0, 0, 0, 0.35); - padding: 0.2rem 0.35rem; - border-radius: 6px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; -} -.goa-pay-https { - font-size: 0.78rem; - font-weight: 700; +.goa-pay-link { color: #93c5fd; text-decoration: none; white-space: nowrap; - margin-left: 0.25rem; } -.goa-pay-https:hover { +.goa-pay-link:hover { text-decoration: underline; } -.goa-pay-payto-link { - display: inline-block; - margin-top: 0.4rem; - font-size: 0.82rem; - font-weight: 650; - color: #93c5fd; +.goa-pay-link.disabled { + opacity: 0.45; + pointer-events: none; +} +.goa-pay-sep { + margin: 0 0.4rem; + color: #b8a8c9; + font-weight: 500; } .goa-pay-close { display: block; @@ -179,8 +294,7 @@ button.shop-item { cursor: pointer; } -/* Bank (gold/teal) overrides when body is bank palette */ -body .goa-pay-card.bank-theme, +/* Bank (gold/teal) overrides */ .goa-pay-modal.bank-theme .goa-pay-card { background: rgba(42, 32, 24, 0.98); border-color: rgba(232, 168, 56, 0.4); @@ -192,12 +306,21 @@ body .goa-pay-card.bank-theme, background: linear-gradient(135deg, #5eead4, #0d9488); color: #042f2e; } -.goa-pay-modal.bank-theme .goa-pay-uri { - color: #f0d090; +.goa-pay-modal.bank-theme .goa-pay-link { + color: #5eead4; } -.goa-pay-modal.bank-theme .goa-pay-payto-link, .goa-pay-modal.bank-theme .goa-pay-x, .goa-pay-modal.bank-theme .goa-pay-close { color: #e8c878; border-color: rgba(232, 168, 56, 0.35); } +.goa-pay-modal.bank-theme .goa-pay-settle { + border-color: rgba(232, 168, 56, 0.3); + background: rgba(0, 0, 0, 0.28); +} +.goa-pay-modal.bank-theme .goa-pay-settle-toggle { + color: #e8c878; +} +.goa-pay-modal.bank-theme .goa-pay-settle-body { + border-top-color: rgba(232, 168, 56, 0.25); +} diff --git a/configs/merchant-landing/shop-pay.js b/configs/merchant-landing/shop-pay.js index 197ce98..b8a1543 100644 --- a/configs/merchant-landing/shop-pay.js +++ b/configs/merchant-landing/shop-pay.js @@ -1,27 +1,30 @@ /** - * GOA shop pay popup — public pay-template + payto (no merchant secrets). - * QR: white modules, centered official qr-logo (DD 90). + * GOA shop pay popup — public only (no merchant secrets). + * Flow: POST templates/{id} → taler://pay/… + payto links. + * + * QR display matches taler-merchant-webui QR_Taler + * (@gnu-taler/web-util QR.tsx): animated #0042B3 conic ring + qr-logo.svg. + * Uses qrcode-generator (same lib as webui) via global QRCode if present, + * else falls back to canvas from qrcode.min.js (davidshimjs). */ (function () { var MERCHANT_HOST = "taler.hacktivism.ch"; - var INSTANCE = "goa-demo-cp4zqk"; + /* goa-shop: dedicated instance with fixed-order product templates */ + var INSTANCE = "goa-shop"; var SHOP_PAYTO = - "payto://x-taler-bank/bank.hacktivism.ch/goa-demo-cp4zqk?receiver-name=GOA%20Demo%20Shop%20cp4zqk"; - var SHOP_PAYTO_HTTPS = - "https://bank.hacktivism.ch/webui/"; + "payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop"; + var SHOP_PAYTO_HTTPS = "https://bank.hacktivism.ch/webui/"; - function payTemplateUri(productId) { - return ( - "taler://pay-template/" + - MERCHANT_HOST + - "/instances/" + - INSTANCE + - "/" + - encodeURIComponent(productId) - ); + function introBase() { + var b = document.querySelector("base"); + return b && b.href ? b.href : "/intro/"; } - function payTemplateHttps(productId) { + function logoSrc() { + return introBase() + "qr-logo.svg"; + } + + function templateHttps(productId) { return ( "https://" + MERCHANT_HOST + @@ -32,9 +35,15 @@ ); } - function introBase() { - var b = document.querySelector("base"); - return b && b.href ? b.href : "/intro/"; + function payTemplateUri(productId) { + return ( + "taler://pay-template/" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/" + + encodeURIComponent(productId) + ); } function ensureModal() { @@ -52,37 +61,233 @@ ' ' + '

Pay with Taler

' + '

' + - '
' + - '
' + - ' ' + + '

Preparing payment…

' + + '

① Wallet payment

' + + '

Scan with the GNU Taler Wallet to pay this product

' + + '
' + + '
' + + ' ' + "
" + - '

Scan with GNU Taler Wallet

' + ' Open payment link →' + - '

Payment URI (wallet)

' + - '

' + - ' (https)

' + - '

Merchant payto (settlement)

' + - '

' + - ' (https)

' + - ' Open payto:// link' + + '

' + + ' taler:// URI' + + ' ·' + + ' HTTPS' + + "

" + + '
' + + ' " + + ' " + + "
" + ' ' + "
"; document.body.appendChild(m); function close() { m.classList.remove("open"); m.hidden = true; + // collapse settlement on close + var body = document.getElementById("goa-pay-settle-body"); + var btn = document.getElementById("goa-pay-settle-toggle"); + if (body) body.hidden = true; + if (btn) { + btn.setAttribute("aria-expanded", "false"); + m.classList.remove("settle-open"); + } } m.addEventListener("click", function (e) { if (e.target === m) close(); }); document.getElementById("goa-pay-x").onclick = close; document.getElementById("goa-pay-close").onclick = close; + document.getElementById("goa-pay-settle-toggle").onclick = function () { + var body = document.getElementById("goa-pay-settle-body"); + var btn = document.getElementById("goa-pay-settle-toggle"); + var open = body.hidden; + body.hidden = !open; + btn.setAttribute("aria-expanded", open ? "true" : "false"); + m.classList.toggle("settle-open", open); + if (open) { + // paint payto QR when first expanded + renderQr(document.getElementById("goa-pay-qr-payto"), SHOP_PAYTO, 140); + } + }; document.addEventListener("keydown", function (e) { if (e.key === "Escape" && m.classList.contains("open")) close(); }); return m; } + /** + * Render URI as a real PNG (not a live canvas). + * davidshimjs paints canvas then often hides it for an img; if the modal is + * still display:none, layout collapses and you only see the blue ring. + * We always encode off-DOM and inject a fixed-size image. + */ + function renderQr(hostEl, text, size) { + if (!hostEl) return; + hostEl.innerHTML = ""; + size = size || 220; + if (!text) { + hostEl.innerHTML = '

'; + return; + } + if (typeof QRCode === "undefined") { + hostEl.innerHTML = + '

QR library missing (qrcode.min.js)

'; + return; + } + var level = + QRCode.CorrectLevel && QRCode.CorrectLevel.M != null + ? QRCode.CorrectLevel.M + : QRCode.CorrectLevel && QRCode.CorrectLevel.L != null + ? QRCode.CorrectLevel.L + : 1; + var scratch = document.createElement("div"); + scratch.setAttribute("aria-hidden", "true"); + scratch.style.cssText = + "position:fixed;left:-9999px;top:0;width:" + + size + + "px;height:" + + size + + "px;overflow:hidden;opacity:0;pointer-events:none"; + document.body.appendChild(scratch); + var dataUrl = ""; + try { + new QRCode(scratch, { + text: String(text), + width: size, + height: size, + colorDark: "#000000", + colorLight: "#ffffff", + correctLevel: level, + }); + var canvas = scratch.querySelector("canvas"); + var libImg = scratch.querySelector("img"); + if (canvas && canvas.width > 0) { + try { + dataUrl = canvas.toDataURL("image/png"); + } catch (e1) {} + } + if (!dataUrl && libImg && libImg.src && libImg.src.indexOf("data:") === 0) { + dataUrl = libImg.src; + } + } catch (err) { + dataUrl = ""; + } + if (scratch.parentNode) scratch.parentNode.removeChild(scratch); + + if (!dataUrl) { + hostEl.innerHTML = + '

QR encode failed

'; + return; + } + var img = document.createElement("img"); + img.alt = "QR code"; + img.width = size; + img.height = size; + img.src = dataUrl; + img.style.display = "block"; + img.style.width = size + "px"; + img.style.height = size + "px"; + img.style.maxWidth = "100%"; + img.style.margin = "0 auto"; + img.style.background = "#fff"; + hostEl.appendChild(img); + } + + function setLogo(imgEl) { + if (!imgEl) return; + imgEl.src = logoSrc(); + imgEl.onerror = function () { + imgEl.style.display = "none"; + }; + imgEl.style.display = ""; + } + + function normalizePayUri(uri) { + if (!uri) return ""; + return String(uri) + .replace(/taler\.hacktivism\.ch:443/g, "taler.hacktivism.ch") + .replace(/:443\//g, "/") + .replace(/:443\?/g, "?"); + } + + /** Public: template → order → taler_pay_uri (no secrets). */ + function createPayUri(productId) { + var url = templateHttps(productId); + return fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + cache: "no-store", + }) + .then(function (r) { + if (!r.ok) throw new Error("template POST HTTP " + r.status); + return r.json(); + }) + .then(function (created) { + var oid = created.order_id; + var tok = created.token; + if (!oid || !tok) throw new Error("no order_id/token"); + var statusUrl = + "https://" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/orders/" + + encodeURIComponent(oid) + + "?token=" + + encodeURIComponent(tok); + return fetch(statusUrl, { cache: "no-store" }).then(function (r) { + return r.text().then(function (t) { + var pay = ""; + try { + var d = JSON.parse(t); + pay = d.taler_pay_uri || ""; + } catch (e) {} + if (!pay) { + pay = + "taler://pay/" + + MERCHANT_HOST + + "/instances/" + + INSTANCE + + "/" + + oid + + "/?c=" + + tok; + } + return { + taler_pay_uri: normalizePayUri(pay), + order_id: oid, + token: tok, + status_https: statusUrl.replace( + "taler.hacktivism.ch:443", + "taler.hacktivism.ch" + ), + template_https: url, + }; + }); + }); + }); + } + function showPay(productId, name, amount) { var m = ensureModal(); if (document.body && document.body.getAttribute("data-shop-theme") === "bank") { @@ -90,48 +295,73 @@ } else { m.classList.remove("bank-theme"); } - var uri = payTemplateUri(productId); - var httpsUri = payTemplateHttps(productId); document.getElementById("goa-pay-title").textContent = name || productId; document.getElementById("goa-pay-amount").textContent = amount || ""; + var status = document.getElementById("goa-pay-status"); + status.textContent = "Preparing payment…"; + status.className = "goa-pay-hint"; var open = document.getElementById("goa-pay-open"); - open.href = uri; + open.removeAttribute("href"); + open.classList.add("disabled"); + open.onclick = null; + var payLink = document.getElementById("goa-pay-uri"); + payLink.removeAttribute("href"); + payLink.classList.add("disabled"); + payLink.textContent = "taler:// URI"; + document.getElementById("goa-pay-uri-https").href = templateHttps(productId); + document.getElementById("goa-pay-uri-https").textContent = "HTTPS"; + var paytoLink = document.getElementById("goa-pay-payto"); + paytoLink.href = SHOP_PAYTO; + paytoLink.textContent = "payto:// URI"; + document.getElementById("goa-pay-payto-https").href = SHOP_PAYTO_HTTPS; + document.getElementById("goa-pay-payto-https").textContent = "HTTPS"; - document.getElementById("goa-pay-uri").textContent = uri; - var uriHttps = document.getElementById("goa-pay-uri-https"); - uriHttps.href = httpsUri; - uriHttps.textContent = "(https)"; + setLogo(document.getElementById("goa-pay-qr-pay-logo")); - document.getElementById("goa-pay-payto").textContent = SHOP_PAYTO; - var paytoHttps = document.getElementById("goa-pay-payto-https"); - paytoHttps.href = SHOP_PAYTO_HTTPS; - paytoHttps.textContent = "(https)"; - document.getElementById("goa-pay-payto-link").href = SHOP_PAYTO; - - var qrHost = document.getElementById("goa-pay-qr"); - qrHost.innerHTML = ""; - var logo = document.getElementById("goa-pay-logo"); - logo.src = introBase() + "qr-logo.svg"; - logo.onerror = function () { - logo.style.display = "none"; - }; - logo.style.display = ""; - - if (typeof QRCode === "undefined") { - qrHost.innerHTML = '

QR library missing

'; - } else { - new QRCode(qrHost, { - text: uri, - width: 220, - height: 220, - colorDark: "#000000", - colorLight: "#ffffff", - correctLevel: QRCode.CorrectLevel.M, - }); - } + // Reset settlement panel (collapsed until user expands) + var settleBody = document.getElementById("goa-pay-settle-body"); + var settleBtn = document.getElementById("goa-pay-settle-toggle"); + if (settleBody) settleBody.hidden = true; + if (settleBtn) settleBtn.setAttribute("aria-expanded", "false"); + m.classList.remove("settle-open"); + var paytoHost = document.getElementById("goa-pay-qr-payto"); + if (paytoHost) paytoHost.innerHTML = ""; + // Open first so layout exists, then paint wallet QR only m.hidden = false; m.classList.add("open"); + renderQr( + document.getElementById("goa-pay-qr-pay"), + payTemplateUri(productId), + 220 + ); + + createPayUri(productId) + .then(function (info) { + var pay = info.taler_pay_uri; + open.href = pay; + open.classList.remove("disabled"); + open.textContent = "Open payment link →"; + payLink.href = pay; + payLink.classList.remove("disabled"); + payLink.textContent = "taler:// URI"; + var uh = document.getElementById("goa-pay-uri-https"); + uh.href = info.status_https || info.template_https; + uh.textContent = "HTTPS"; + // Live unpaid taler://pay (fresh order each open) + renderQr(document.getElementById("goa-pay-qr-pay"), pay, 220); + status.textContent = "Ready — scan QR or open in wallet"; + }) + .catch(function (err) { + status.textContent = + "Payment setup failed: " + (err && err.message ? err.message : err); + status.className = "goa-pay-hint err"; + open.textContent = "Retry"; + open.onclick = function (e) { + e.preventDefault(); + showPay(productId, name, amount); + }; + }); } function bind() {