-
00
-
:
-
00
-
:
-
00
+
+
+
+
+
+

+
GM Pay
+
+
+
+
+
+ EN
+
-
- 时
- 分
- 秒
+
+
+
+
+
+
+
+
+
+
Amount to pay
+
+ --
+
+
+
--
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Scan or copy address to pay
+
+
+
+
--:--
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Checking blockchain
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Payment Successful
+
Redirecting…
+
+
+
+
+
+
+
+
+
Payment Expired
+
Please initiate a new payment
+
+
+
+
+
+
+
+
+
Connection Timeout
+
Unable to connect to the payment server
+
+
+
+
+
+
+
+
+
+
+
+
Order Not Found
+
The order does not exist or has already expired
+
+
+
-
+
+
+
+
+
+
+
+
Copied
+
+
+
+
-
-
-
-
-
diff --git a/src/static/payment.css b/src/static/payment.css
new file mode 100644
index 0000000..67e1b4b
--- /dev/null
+++ b/src/static/payment.css
@@ -0,0 +1,395 @@
+/* =============================================
+ iOS 26 Liquid Glass Design System
+ ============================================= */
+
+/* ---------- CSS Variables ---------- */
+:root, [data-theme="light"] {
+ --background: #f2f2f7;
+ --foreground: #1c1c1e;
+ --card: rgba(255,255,255,0.72);
+ --card-foreground: #1c1c1e;
+ --popover: rgba(255,255,255,0.88);
+ --popover-foreground:#1c1c1e;
+ --primary: #ffffff;
+ --primary-foreground:#1c1c1e;
+ --primary-hover: #e5e5ea;
+ --secondary: rgba(120,120,128,0.12);
+ --muted: rgba(120,120,128,0.08);
+ --muted-foreground: #8e8e93;
+ --accent: rgba(255,255,255,0.12);
+ --accent-foreground: #1c1c1e;
+ --border: rgba(60,60,67,0.12);
+ --border-hover: rgba(60,60,67,0.22);
+ --success: #34c759;
+ --warning: #ff9500;
+ --destructive: #ff3b30;
+
+ --glass-bg: rgba(255,255,255,0.65);
+ --glass-border: rgba(255,255,255,0.8);
+ --glass-shadow: 0 8px 40px rgba(0,0,0,.10), 0 1.5px 0 rgba(255,255,255,.9) inset;
+ --card-shadow: 0 4px 24px rgba(0,0,0,.08);
+ --popover-shadow: 0 12px 40px rgba(0,0,0,.14);
+
+ --blob-1: rgba(200,200,200,0.18);
+ --blob-2: rgba(52,199,89,0.14);
+ --blob-3: rgba(255,149,0,0.10);
+}
+
+[data-theme="dark"] {
+ --background: #000000;
+ --foreground: #f5f5f7;
+ --card: rgba(28,28,30,0.82);
+ --card-foreground: #f5f5f7;
+ --popover: rgba(44,44,46,0.92);
+ --popover-foreground:#e5e5ea;
+ --primary: #ffffff;
+ --primary-foreground:#1c1c1e;
+ --primary-hover: #e5e5ea;
+ --secondary: rgba(120,120,128,0.18);
+ --muted: rgba(120,120,128,0.12);
+ --muted-foreground: #636366;
+ --accent: rgba(255,255,255,0.15);
+ --accent-foreground: #ffffff;
+ --border: rgba(255,255,255,0.1);
+ --border-hover: rgba(255,255,255,0.18);
+ --success: #30d158;
+ --warning: #ff9f0a;
+ --destructive: #ff453a;
+
+ --glass-bg: rgba(28,28,30,0.75);
+ --glass-border: rgba(255,255,255,0.10);
+ --glass-shadow: 0 8px 40px rgba(0,0,0,.55), 0 1px 0 rgba(255,255,255,.06) inset;
+ --card-shadow: 0 4px 32px rgba(0,0,0,.55);
+ --popover-shadow: 0 12px 40px rgba(0,0,0,.5);
+
+ --blob-1: rgba(255,255,255,0.14);
+ --blob-2: rgba(48,209,88,0.10);
+ --blob-3: rgba(255,159,10,0.08);
+}
+
+/* ---------- Ambient blobs ---------- */
+.blob {
+ position: absolute;
+ border-radius: 50%;
+ filter: blur(80px);
+ animation: blob-drift 18s ease-in-out infinite alternate;
+}
+.blob-1 {
+ width: 420px; height: 420px;
+ background: var(--blob-1);
+ top: -120px; left: -100px;
+ animation-delay: 0s;
+}
+.blob-2 {
+ width: 340px; height: 340px;
+ background: var(--blob-2);
+ bottom: -80px; right: -60px;
+ animation-delay: -6s;
+}
+.blob-3 {
+ width: 260px; height: 260px;
+ background: var(--blob-3);
+ top: 45%; left: 55%;
+ animation-delay: -12s;
+}
+@keyframes blob-drift {
+ 0% { transform: translate(0,0) scale(1); }
+ 50% { transform: translate(30px,-20px) scale(1.06); }
+ 100% { transform: translate(-20px,30px) scale(0.96); }
+}
+
+/* ---------- Liquid Glass Card ---------- */
+.glass-card {
+ background: var(--glass-bg);
+ -webkit-backdrop-filter: blur(28px) saturate(1.6);
+ backdrop-filter: blur(28px) saturate(1.6);
+ border: 1px solid var(--glass-border);
+ border-radius: 22px;
+ box-shadow: var(--glass-shadow);
+ transition: background 0.3s, border-color 0.3s, box-shadow 0.3s;
+}
+
+/* ---------- Flippable Status Card ---------- */
+.status-card {
+ position: relative;
+ perspective: 1600px;
+ transition: height 0.38s cubic-bezier(.22,1,.36,1);
+}
+.status-card-inner {
+ position: relative;
+ width: 100%;
+ transform-style: preserve-3d;
+ transition: transform 0.82s cubic-bezier(.22,.9,.24,1);
+}
+.status-card.is-flipped .status-card-inner {
+ transform: rotateY(180deg);
+}
+.status-face {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ -webkit-backface-visibility: hidden;
+ backface-visibility: hidden;
+ transform-style: preserve-3d;
+}
+.status-face-front {
+ z-index: 2;
+}
+.status-face-back {
+ transform: rotateY(180deg);
+}
+.state-panel-shell {
+ padding-top: 2px;
+}
+.state-screen {
+ display: none;
+}
+.state-screen.is-active {
+ display: flex;
+}
+
+/* ---------- Timer ---------- */
+.timer-ring-track {
+ stroke: var(--border);
+ transition: stroke .3s;
+}
+.timer-ring-progress {
+ transform: rotate(-90deg);
+ transform-origin: 50% 50%;
+ transition: stroke-dashoffset 1s linear, stroke .5s;
+}
+.timer-icon {
+ color: var(--muted-foreground);
+}
+
+/* ---------- QR wrapper ---------- */
+.qr-wrapper {
+ background: #ffffff;
+ border-radius: 18px;
+ padding: 14px;
+ box-shadow: 0 2px 16px rgba(0,0,0,.12), 0 0 0 1px rgba(0,0,0,.04);
+ display: inline-block;
+}
+
+/* ---------- iOS Pill button ---------- */
+.ios-pill {
+ background: var(--glass-bg);
+ -webkit-backdrop-filter: blur(16px) saturate(1.4);
+ backdrop-filter: blur(16px) saturate(1.4);
+ border: 1px solid var(--glass-border);
+ border-radius: 99px;
+ padding: 6px 13px;
+ color: var(--card-foreground);
+ transition: background 0.15s, opacity 0.15s;
+}
+.ios-pill:hover { opacity: 0.82; }
+.ios-pill:active { opacity: 0.6; }
+.ios-pill.w-9 { padding: 0; justify-content: center; }
+
+/* ---------- iOS Rows ---------- */
+.ios-row {
+ display: flex;
+ align-items: center;
+ padding: 14px 16px;
+ transition: background 0.12s;
+}
+.ios-row:active { background: var(--muted); }
+
+/* ---------- Icon button ---------- */
+.ios-icon-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px; height: 32px;
+ border-radius: 10px;
+ color: var(--muted-foreground);
+ background: var(--secondary);
+ transition: background 0.15s, color 0.15s;
+ flex-shrink: 0;
+}
+.ios-icon-btn:hover { background: var(--accent); color: var(--primary); }
+
+/* ---------- Primary CTA button ---------- */
+.ios-btn-primary {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--primary);
+ color: var(--primary-foreground);
+ border: none;
+ border-radius: 16px;
+ padding: 16px 24px;
+ font-size: 15px;
+ font-weight: 600;
+ letter-spacing: -0.2px;
+ cursor: pointer;
+ transition: background 0.15s, transform 0.1s, opacity 0.15s;
+ box-shadow: 0 4px 18px rgba(0,0,0,0.12), 0 1px 0 rgba(255,255,255,.2) inset;
+}
+[data-theme="dark"] .ios-btn-primary {
+ box-shadow: 0 4px 18px rgba(255,255,255,0.18), 0 1px 0 rgba(255,255,255,.12) inset;
+}
+.ios-btn-primary:hover { background: var(--primary-hover); }
+.ios-btn-primary:active { transform: scale(0.97); opacity: 0.9; }
+.ios-btn-primary:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
+
+/* ---------- Secondary button ---------- */
+.ios-btn-secondary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--secondary);
+ color: var(--card-foreground);
+ border: 1px solid var(--border);
+ border-radius: 13px;
+ padding: 11px 28px;
+ font-size: 15px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s, opacity 0.15s;
+}
+.ios-btn-secondary:hover { background: var(--accent); }
+.ios-btn-secondary:active { opacity: 0.7; }
+
+/* ---------- State icon circle ---------- */
+.ios-state-icon {
+ width: 76px; height: 76px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0 auto;
+}
+
+/* ---------- Context Menu ---------- */
+.ios-menu {
+ background: var(--popover);
+ -webkit-backdrop-filter: blur(30px) saturate(1.8);
+ backdrop-filter: blur(30px) saturate(1.8);
+ border: 1px solid var(--glass-border);
+ border-radius: 16px;
+ box-shadow: var(--popover-shadow);
+ overflow: hidden;
+}
+.ios-menu.is-open {
+ opacity: 1;
+ transform: translateY(0);
+ pointer-events: all;
+}
+.ios-menu-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 11px 15px;
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--card-foreground);
+ cursor: pointer;
+ transition: background 0.1s;
+}
+.ios-menu-item:hover { background: var(--accent); }
+.ios-menu-item.is-selected { color: var(--primary); font-weight: 600; }
+.ios-menu-item + .ios-menu-item { border-top: 1px solid var(--border); }
+
+/* ---------- Select states ---------- */
+.select-trigger.is-open { opacity: 0.8; }
+.select-trigger.is-open .select-chevron { transform: rotate(180deg); }
+
+/* ---------- Toast ---------- */
+#toast {
+ position: fixed;
+ bottom: 36px;
+ left: 50%;
+ transform: translateX(-50%) translateY(12px);
+ background: rgba(28,28,30,0.88);
+ -webkit-backdrop-filter: blur(20px);
+ backdrop-filter: blur(20px);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 500;
+ padding: 10px 20px;
+ border-radius: 20px;
+ border: 1px solid rgba(255,255,255,0.12);
+ box-shadow: 0 4px 20px rgba(0,0,0,.3);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.22s, transform 0.22s;
+ z-index: 9999;
+ white-space: nowrap;
+}
+[data-theme="light"] #toast {
+ background: rgba(44,44,46,0.9);
+}
+
+/* ---------- Animations ---------- */
+@keyframes state-in {
+ from { opacity: 0; transform: translateY(12px) scale(0.96); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+/* ---------- View Transition ---------- */
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation: none;
+ mix-blend-mode: normal;
+}
+::view-transition-old(root) { z-index: 1; }
+::view-transition-new(root) { z-index: 9; }
+
+/* ---------- Debug toolbar ---------- */
+#debug-bar {
+ position: fixed; bottom: 20px; right: 20px; z-index: 9999;
+ display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
+}
+#debug-toggle {
+ width: 36px; height: 36px; border-radius: 50%; border: none;
+ background: #7c3aed; color: #fff; cursor: pointer;
+ display: flex; align-items: center; justify-content: center;
+ box-shadow: 0 2px 12px rgba(124,58,237,.5);
+ transition: background .15s, transform .1s;
+}
+#debug-toggle:hover { background: #6d28d9; }
+#debug-toggle:active { transform: scale(.92); }
+#debug-panel {
+ display: none; flex-direction: column; gap: 5px;
+ background: rgba(15,15,20,.92);
+ -webkit-backdrop-filter: blur(10px);
+ backdrop-filter: blur(10px);
+ border: 1px solid rgba(124,58,237,.35); border-radius: 14px;
+ padding: 12px; min-width: 150px;
+ box-shadow: 0 8px 32px rgba(0,0,0,.5);
+}
+#debug-panel.is-open { display: flex; }
+.debug-label {
+ font-size: 10px; font-weight: 700; color: #a78bfa;
+ letter-spacing: .12em; text-transform: uppercase;
+ margin-bottom: 4px; padding-bottom: 6px;
+ border-bottom: 1px solid rgba(255,255,255,.08);
+}
+.debug-btn {
+ background: rgba(255,255,255,.07); color: #d1d5db;
+ border: 1px solid rgba(255,255,255,.1); border-radius: 8px;
+ padding: 6px 10px; font-size: 12px; font-weight: 500;
+ cursor: pointer; text-align: left; font-family: inherit;
+ transition: background .12s, color .12s;
+}
+.debug-btn:hover { background: rgba(255,255,255,.14); color: #fff; }
+.debug-btn--reset:hover { border-color: #60a5fa; color: #93c5fd; }
+.debug-btn--success:hover { border-color: #34d399; color: #6ee7b7; }
+.debug-btn--expired:hover { border-color: #f87171; color: #fca5a5; }
+.debug-btn--timeout:hover { border-color: #fb923c; color: #fdba74; }
+.debug-btn--urgent:hover { border-color: #facc15; color: #fde68a; }
+.debug-btn--not-found {
+ background: rgba(142,142,147,.15);
+ color: #8e8e93;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .status-card,
+ .status-card-inner,
+ .status-face,
+ .timer-ring-progress {
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ }
+}
diff --git a/src/static/payment.js b/src/static/payment.js
new file mode 100644
index 0000000..8e2b252
--- /dev/null
+++ b/src/static/payment.js
@@ -0,0 +1,585 @@
+
+
+// DOM helpers
+const $ = (id) => document.getElementById(id);
+const $$ = (sel) => document.querySelectorAll(sel);
+
+// ---- Network Tag ----
+const _CHAIN_PRESETS = {
+ Ethereum: { bg: 'hsla(0,0%,55%,0.25)', color: 'hsla(0,0%,55%,1)', icon: 'ethereum' },
+ Solana: { bg: 'hsla(256,85%,65%,0.25)', color: 'hsla(256,85%,65%,1)', icon: 'solana' },
+ BSC: { bg: 'hsla(46,91%,49%,0.25)', color: 'hsla(46,91%,49%,1)', icon: 'bsc' },
+ Arbitrum: { bg: 'hsla(202,100%,54%,0.25)', color: 'hsla(202,100%,54%,1)', icon: 'arbitrum' },
+ TRON: { bg: 'hsla(350,100%,47%,0.20)', color: 'hsla(350,100%,47%,1)', icon: 'tron' },
+ Polygon: { bg: 'hsla(263,73%,56%,0.25)', color: 'hsla(263,73%,56%,1)', icon: 'polygon' },
+ Base: { bg: 'hsla(240,100%,61%,0.25)', color: 'hsla(240,100%,61%,1)', icon: 'base' },
+ OP: { bg: 'hsla(353,99%,51%,0.25)', color: 'hsla(353,99%,51%,1)', icon: 'op' },
+ HyperEVM: { bg: 'hsla(166,94%,79%,0.20)', color: 'hsla(166,94%,79%,1)', icon: 'hyperevm' },
+ Plasma: { bg: 'hsla(166,64%,32%,0.25)', color: 'hsla(166,64%,32%,1)', icon: 'plasma' },
+ Bitcoin: { bg: 'hsla(33,93%,54%,0.20)', color: 'hsla(33,93%,54%,1)', icon: 'bitcoin' },
+ Binance: { bg: 'hsla(46,91%,49%,0.24)', color: 'hsla(46,91%,49%,1)', icon: 'binance' },
+};
+const _CHAIN_ALIASES = {
+ evm:'Ethereum', eth:'Ethereum', ethereum:'Ethereum',
+ bsc:'BSC', bnb:'BSC',
+ arbitrum:'Arbitrum', arb:'Arbitrum',
+ sol:'Solana', solana:'Solana',
+ tron:'TRON', trx:'TRON',
+ polygon:'Polygon', matic:'Polygon', pol:'Polygon',
+ base:'Base',
+ op:'OP', optimism:'OP',
+ hyperevm:'HyperEVM', hyperliquid:'HyperEVM', hype:'HyperEVM',
+ plasma:'Plasma', xpl:'Plasma',
+ bitcoin:'Bitcoin', btc:'Bitcoin',
+ binance:'Binance', bnb_chain:'Binance',
+};
+function _resolveChain(name) {
+ if (!name) return null;
+ const k = name.trim().toLowerCase();
+ const canonical = _CHAIN_ALIASES[k] || Object.keys(_CHAIN_PRESETS).find(c => c.toLowerCase() === k);
+ return canonical ? { label: canonical, ..._CHAIN_PRESETS[canonical] } : null;
+}
+function networkTag(network) {
+ const chain = _resolveChain(network);
+ if (!chain) return `
${network}`;
+ return `
${chain.label}`;
+}
+
+// Initialize Lucide icons
+if (typeof lucide !== 'undefined') lucide.createIcons();
+
+// Theme
+let currentTheme = localStorage.getItem('theme') || 'dark';
+applyTheme(currentTheme);
+
+function applyTheme(t) {
+ currentTheme = t;
+ document.documentElement.setAttribute('data-theme', t);
+ localStorage.setItem('theme', t);
+ $('icon-moon').style.display = t === 'light' ? 'block' : 'none';
+ $('icon-sun').style.display = t === 'dark' ? 'block' : 'none';
+}
+
+function toggleTheme(e) {
+ const next = currentTheme === 'light' ? 'dark' : 'light';
+ if (!document.startViewTransition) { applyTheme(next); return; }
+ const x = e ? e.clientX : window.innerWidth / 2;
+ const y = e ? e.clientY : window.innerHeight / 2;
+ const r = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));
+ const transition = document.startViewTransition(() => applyTheme(next));
+ transition.ready.then(() => {
+ const clip = [`circle(0px at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`];
+ document.documentElement.animate(
+ { clipPath: clip },
+ { duration: 420, easing: 'ease-in-out', pseudoElement: '::view-transition-new(root)' }
+ );
+ });
+}
+
+// i18n
+const LANGS = {
+ en: {
+ scan_title: 'Scan or copy address to pay',
+ amount_to_pay: 'Amount to pay',
+ payment_address: 'Payment address',
+ i_have_transferred: 'I have transferred',
+ checking_blockchain: 'Checking blockchain…',
+ expires: 'Expires',
+ copied: 'Copied to clipboard',
+ verifying: 'Verifying…',
+ payment_success: 'Payment Successful',
+ redirecting: 'Redirecting…',
+ payment_expired: 'Payment Expired',
+ expired_sub: 'Please initiate a new payment',
+ order_id: 'Order ID',
+ order_amount: 'Order amount',
+ network_timeout: 'Connection Timeout',
+ timeout_sub: 'Unable to connect to the payment server',
+ retry: 'Retry',
+ back: 'Back',
+ order_not_found: 'Order Not Found',
+ not_found_sub: 'The order does not exist or has already expired',
+ },
+ zh: {
+ scan_title: '扫码或复制地址付款',
+ amount_to_pay: '付款金额',
+ payment_address: '付款地址',
+ i_have_transferred: '我已转账',
+ checking_blockchain: '链上核验中…',
+ expires: '到期时间',
+ copied: '已复制到剪贴板',
+ verifying: '核验中…',
+ payment_success: '支付成功',
+ redirecting: '正在跳转…',
+ payment_expired: '支付已过期',
+ expired_sub: '请重新发起支付',
+ order_id: '订单 ID',
+ order_amount: '订单金额',
+ network_timeout: '连接超时',
+ timeout_sub: '无法连接至支付服务器',
+ retry: '重试',
+ back: '返回',
+ order_not_found: '订单不存在',
+ not_found_sub: '待支付订单不存在或已过期',
+ },
+ ja: {
+ scan_title: 'アドレスをスキャンまたはコピーして支払う',
+ amount_to_pay: '支払い金額',
+ payment_address: '支払いアドレス',
+ i_have_transferred: '送金しました',
+ checking_blockchain: 'ブロックチェーン確認中…',
+ expires: '有効期限',
+ copied: 'コピーしました',
+ verifying: '確認中…',
+ payment_success: '支払い完了',
+ redirecting: 'リダイレクト中…',
+ payment_expired: '支払い期限切れ',
+ expired_sub: '新しい支払いを開始してください',
+ order_id: '注文 ID',
+ order_amount: '注文金額',
+ network_timeout: '接続タイムアウト',
+ timeout_sub: '支払いサーバーに接続できません',
+ retry: '再試行',
+ back: '戻る',
+ order_not_found: '注文が見つかりません',
+ not_found_sub: '注文が存在しないか、すでに期限切れです',
+ },
+ ko: {
+ scan_title: '주소를 스캔하거나 복사하여 결제',
+ amount_to_pay: '결제 금액',
+ payment_address: '결제 주소',
+ i_have_transferred: '이체 완료',
+ checking_blockchain: '블록체인 확인 중…',
+ expires: '만료 시간',
+ copied: '복사됨',
+ verifying: '확인 중…',
+ payment_success: '결제 성공',
+ redirecting: '리다이렉트 중…',
+ payment_expired: '결제 만료',
+ expired_sub: '새로운 결제를 시작하세요',
+ order_id: '주문 ID',
+ order_amount: '주문 금액',
+ network_timeout: '연결 시간 초과',
+ timeout_sub: '결제 서버에 연결할 수 없습니다',
+ retry: '다시 시도',
+ back: '돌아가기',
+ order_not_found: '주문 없음',
+ not_found_sub: '주문이 존재하지 않거나 이미 만료되었습니다',
+ },
+ 'zh-hk': {
+ scan_title: '掃碼或複製地址付款',
+ amount_to_pay: '付款金額',
+ payment_address: '付款地址',
+ i_have_transferred: '我已轉帳',
+ checking_blockchain: '鏈上核驗中…',
+ expires: '到期時間',
+ copied: '已複製到剪貼簿',
+ verifying: '核驗中…',
+ payment_success: '支付成功',
+ redirecting: '正在跳轉…',
+ payment_expired: '支付已過期',
+ expired_sub: '請重新發起支付',
+ order_id: '訂單 ID',
+ order_amount: '訂單金額',
+ network_timeout: '連線逾時',
+ timeout_sub: '無法連線至支付伺服器',
+ retry: '重試',
+ back: '返回',
+ order_not_found: '訂單不存在',
+ not_found_sub: '待支付訂單不存在或已過期',
+ },
+ ru: {
+ scan_title: 'Отсканируйте или скопируйте адрес для оплаты',
+ amount_to_pay: 'Сумма к оплате',
+ payment_address: 'Адрес для оплаты',
+ i_have_transferred: 'Я перевёл',
+ checking_blockchain: 'Проверка блокчейна…',
+ expires: 'Истекает',
+ copied: 'Скопировано',
+ verifying: 'Проверка…',
+ payment_success: 'Оплата прошла успешно',
+ redirecting: 'Перенаправление…',
+ payment_expired: 'Срок оплаты истёк',
+ expired_sub: 'Пожалуйста, создайте новый платёж',
+ order_id: 'Заказ ID',
+ order_amount: 'Сумма заказа',
+ network_timeout: 'Тайм-аут подключения',
+ timeout_sub: 'Не удалось подключиться к серверу',
+ retry: 'Повторить',
+ back: 'Назад',
+ order_not_found: 'Заказ не найден',
+ not_found_sub: 'Заказ не существует или уже истёк',
+ },
+};
+
+const LANG_LABELS = { en: 'EN', zh: '中文', 'zh-hk': '繁體', ja: '日本語', ko: '한국어', ru: 'RU' };
+const SUPPORTED_LANGS = Object.keys(LANGS); // ['en','zh','ja','ko','zh-hk','ru']
+let currentLang = 'en';
+const t = (key) => LANGS[currentLang]?.[key] ?? key;
+
+function detectLang() {
+ const saved = localStorage.getItem('lang');
+ if (saved && SUPPORTED_LANGS.includes(saved)) return saved;
+ for (const nav of (navigator.languages || [navigator.language])) {
+ const lc = nav.toLowerCase();
+ if (lc.startsWith('zh-hk') || lc.startsWith('zh-tw') || lc.startsWith('zh-mo')) return 'zh-hk';
+ if (lc.startsWith('zh')) return 'zh';
+ if (lc.startsWith('ja')) return 'ja';
+ if (lc.startsWith('ko')) return 'ko';
+ if (lc.startsWith('ru')) return 'ru';
+ if (lc.startsWith('en')) return 'en';
+ }
+ return 'en';
+}
+
+function setLang(lang) {
+ currentLang = lang;
+ localStorage.setItem('lang', lang);
+ $$('[data-i18n]').forEach(el => {
+ const v = t(el.dataset.i18n);
+ if (v) el.textContent = v;
+ });
+ $('lang-label').textContent = LANG_LABELS[lang];
+ $$('#dd-lang-menu .select-option').forEach(o =>
+ o.classList.toggle('is-selected', o.dataset.lang === lang));
+ closeAllSelects();
+ if (ORDER.tradeId) { renderOrderId(); }
+ if (ORDER.amount && !ORDER.amount.startsWith('{{')) {
+ renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
+ }
+ requestAnimationFrame(syncStatusCardHeight);
+}
+
+// Select dropdown
+function toggleSelect(id) {
+ const wrap = $(id);
+ const trigger = wrap.querySelector('.select-trigger');
+ const menu = wrap.querySelector('.select-menu');
+ const wasOpen = menu.classList.contains('is-open');
+ closeAllSelects();
+ if (!wasOpen) {
+ menu.classList.add('is-open');
+ trigger.classList.add('is-open');
+ }
+}
+
+function closeAllSelects() {
+ $$('.select-menu.is-open').forEach(m => {
+ m.classList.remove('is-open');
+ m.closest('.select-wrap').querySelector('.select-trigger').classList.remove('is-open');
+ });
+}
+
+document.addEventListener('click', (e) => {
+ if (!e.target.closest('.select-wrap')) closeAllSelects();
+});
+
+// Clipboard fallback (used when ClipboardJS is unavailable)
+const CHECK_ICON = '
';
+
+function showToast() {
+ const el = $('toast');
+ el.textContent = t('copied');
+ el.style.opacity = '1';
+ el.style.transform = 'translateX(-50%) translateY(0)';
+ clearTimeout(el._tid);
+ el._tid = setTimeout(() => {
+ el.style.opacity = '0';
+ el.style.transform = 'translateX(-50%) translateY(10px)';
+ }, 2000);
+}
+
+function flashCheck(btnId) {
+ const btn = $(btnId);
+ if (!btn) return;
+ const orig = btn.innerHTML;
+ btn.innerHTML = CHECK_ICON;
+ lucide.createIcons({ nodes: [btn] });
+ setTimeout(() => { btn.innerHTML = orig; lucide.createIcons({ nodes: [btn] }); }, 1800);
+}
+
+// Transfer button
+function handleTransfer() {
+ const btn = $('btn-transferred');
+ const span = btn.querySelector('span');
+ span.textContent = t('verifying');
+ btn.disabled = true;
+ _pollErrors = 0;
+ _pollStopped = false;
+ clearTimeout(_pollTimer);
+ checkOrderStatus();
+ setTimeout(() => {
+ span.textContent = t('i_have_transferred');
+ btn.disabled = false;
+ }, 4000);
+}
+
+// Order
+function truncateAddr(addr) {
+ return addr.length > 10 ? `${addr.slice(0, 4)}...${addr.slice(-6)}` : addr;
+}
+
+function formatAddr(addr) {
+ if (!addr || addr.length <= 10) return addr;
+ const pre = addr.slice(0, 4);
+ const mid = addr.slice(4, -6);
+ const suf = addr.slice(-6);
+ return `
${pre}${mid}${suf}`;
+}
+
+function renderRow(id, label, value) {
+ const el = $(id);
+ if (el) el.innerHTML = `
${label} | ${value} | `;
+}
+
+function renderOrderId() {
+ if (ORDER.tradeId && !ORDER.tradeId.startsWith('{{')) {
+ renderRow('display-order-id', t('order_id'), ORDER.tradeId);
+ }
+}
+
+function initOrder() {
+ if (!ORDER.tradeId || ORDER.tradeId.startsWith('{{')) {
+ showNotFound();
+ return;
+ }
+
+ $('display-amount').textContent = `${ORDER.actualAmount} ${ORDER.token}`;
+ $('field-amount').textContent = `${ORDER.actualAmount} ${ORDER.token}`;
+ $('field-address').innerHTML = formatAddr(ORDER.receiveAddress);
+ if (ORDER.amount && !ORDER.amount.startsWith('{{')) {
+ renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
+ }
+ $('display-network').innerHTML = networkTag(ORDER.network);
+ renderOrderId();
+
+ new QRCode($('qrcode'), {
+ text: ORDER.receiveAddress,
+ width: 176,
+ height: 176,
+ colorDark: '#111111',
+ colorLight: '#ffffff',
+ correctLevel: QRCode.CorrectLevel.M,
+ });
+
+ initCountdown();
+ checkOrderStatus();
+ initClipboard();
+}
+
+// Clipboard
+let _clipboard = null;
+
+function initClipboard() {
+ const targets = [
+ { id: 'copy-amt-box', text: ORDER.actualAmount, iconId: 'btn-copy-amt2' },
+ { id: 'copy-addr-box', text: ORDER.receiveAddress, iconId: 'btn-copy-addr' },
+ { id: 'btn-copy-amount', text: ORDER.actualAmount, iconId: 'btn-copy-amount' },
+ ];
+
+ if (typeof ClipboardJS !== 'undefined') {
+ targets.forEach(({ id, text }) => {
+ const el = $(id);
+ if (el) el.setAttribute('data-clipboard-text', text);
+ });
+ if (_clipboard) _clipboard.destroy();
+ _clipboard = new ClipboardJS(targets.map(c => '#' + c.id).join(', '));
+ _clipboard.on('success', e => {
+ e.clearSelection();
+ const tgt = targets.find(c => c.id === e.trigger.id);
+ showToast();
+ if (tgt) flashCheck(tgt.iconId);
+ });
+ }
+}
+// Countdown color interpolation (green → orange → red by ratio)
+function lerpColor(a, b, t) {
+ return `rgb(${Math.round(a[0]+(b[0]-a[0])*t)},${Math.round(a[1]+(b[1]-a[1])*t)},${Math.round(a[2]+(b[2]-a[2])*t)})`;
+}
+function ratioColor(ratio) {
+ if (ratio >= 0.5) return '#22c55e';
+ if (ratio >= 0.2) return lerpColor([34,197,94], [249,115,22], (0.5 - ratio) / 0.3);
+ return lerpColor([249,115,22], [239,68,68], (0.2 - ratio) / 0.2);
+}
+
+const CIRCUMFERENCE = 2 * Math.PI * 20; // r=20 in iOS SVG ring
+let countdownInterval = null;
+let totalSeconds = 0;
+let _expiresAt = null;
+
+const pad = (n) => String(n).padStart(2, '0');
+
+function initCountdown() {
+ const parseTime = (raw) => new Date(/^\d+$/.test(String(raw)) ? Number(raw) : raw);
+ _expiresAt = parseTime(ORDER.expirationTime);
+ const remaining = Math.max(0, Math.round((_expiresAt - Date.now()) / 1000));
+ if (remaining <= 0) { showExpired(); return; }
+ // 用 createdAt 和 expirationTime 推算总有效时长,否则默认 600 秒
+ const created = parseTime(ORDER.createdAt);
+ totalSeconds = (!isNaN(created.getTime()) && !isNaN(_expiresAt.getTime()))
+ ? Math.max(1, Math.round((_expiresAt - created) / 1000))
+ : 600;
+ $('ring').style.strokeDasharray = CIRCUMFERENCE;
+ // 让初始渲染就从 remaining-1 开始,避免视觉上停顿一秒
+ _expiresAt = new Date(_expiresAt.getTime() - 1000);
+ tickCountdown();
+ countdownInterval = setInterval(tickCountdown, 1000);
+}
+
+function tickCountdown() {
+ const remaining = Math.max(0, Math.round((_expiresAt - Date.now()) / 1000));
+ const h = Math.floor(remaining / 3600);
+ const m = Math.floor((remaining % 3600) / 60);
+ const s = remaining % 60;
+
+ const timeStr = h > 0
+ ? `${pad(h)}:${pad(m)}:${pad(s)}`
+ : `${pad(m)}:${pad(s)}`;
+
+ $('countdown').textContent = timeStr;
+ const inlineEl = $('countdown-inline');
+
+ const color = ratioColor(totalSeconds > 0 ? remaining / totalSeconds : 0);
+ if (inlineEl) { inlineEl.textContent = timeStr; inlineEl.style.color = color; }
+
+ $('ring').style.strokeDashoffset = CIRCUMFERENCE * (1 - (totalSeconds > 0 ? remaining / totalSeconds : 0));
+
+ $('countdown').style.color = color;
+ $('ring').style.stroke = color;
+
+ if (remaining <= 0) {
+ clearInterval(countdownInterval);
+ showExpired();
+ }
+}
+
+// Polling
+let _pollTimer = null;
+let _pollStopped = false;
+let _pollErrors = 0;
+const MAX_POLL_ERRORS = 5;
+
+function checkOrderStatus() {
+ if (_pollStopped || !ORDER.tradeId || ORDER.tradeId.startsWith('{{')) return;
+
+ fetch(`/pay/check-status/${encodeURIComponent(ORDER.tradeId)}`, {
+ headers: { Accept: 'application/json' },
+ signal: AbortSignal.timeout(8000),
+ })
+ .then(res => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+ })
+ .then(({ data }) => {
+ _pollErrors = 0;
+ if (data?.status === 2) onPaymentSuccess();
+ else if (data?.status === 3) showExpired();
+ else scheduleNextPoll();
+ })
+ .catch(() => {
+ if (++_pollErrors >= MAX_POLL_ERRORS) showTimeout();
+ else scheduleNextPoll();
+ });
+}
+
+const scheduleNextPoll = () => {
+ if (!_pollStopped) _pollTimer = setTimeout(checkOrderStatus, 5000);
+};
+
+function stopPolling() {
+ _pollStopped = true;
+ clearTimeout(_pollTimer);
+}
+
+// State panels
+const SCREENS = ['screen-success', 'screen-expired', 'screen-timeout', 'screen-not-found'];
+
+function syncStatusCardHeight() {
+ const card = $('status-card');
+ const inner = card?.querySelector('.status-card-inner');
+ if (!card || !inner) return;
+
+ const activeFace = card.classList.contains('is-flipped')
+ ? card.querySelector('.status-face-back')
+ : card.querySelector('.status-face-front');
+
+ const nextHeight = activeFace?.scrollHeight || 0;
+ if (nextHeight > 0) {
+ card.style.height = `${nextHeight}px`;
+ inner.style.height = `${nextHeight}px`;
+ }
+}
+
+function setActiveScreen(state) {
+ SCREENS.forEach(id => $(id).classList.toggle('is-active', id === `screen-${state}`));
+}
+
+function flipToState(state) {
+ setActiveScreen(state);
+ $('status-card').classList.add('is-flipped');
+ requestAnimationFrame(syncStatusCardHeight);
+}
+
+function showStatePanel(state) {
+ $('order-info').style.display = state === 'not-found' ? 'none' : '';
+ flipToState(state);
+}
+
+function goBack() {
+ if (document.referrer) {
+ window.location.href = document.referrer;
+ } else if (ORDER.redirectUrl && !ORDER.redirectUrl.startsWith('{{')) {
+ window.location.href = ORDER.redirectUrl;
+ } else {
+ history.back();
+ }
+}
+
+function hideStatePanel() {
+ $('status-card').classList.remove('is-flipped');
+ $('order-info').style.display = '';
+ requestAnimationFrame(syncStatusCardHeight);
+}
+
+function onPaymentSuccess() {
+ stopPolling();
+ clearInterval(countdownInterval);
+ showStatePanel('success');
+ if (ORDER.redirectUrl && !ORDER.redirectUrl.startsWith('{{')) {
+ setTimeout(() => { window.location.href = ORDER.redirectUrl; }, 3000);
+ }
+}
+
+function showExpired() {
+ stopPolling();
+ clearInterval(countdownInterval);
+ $('btn-transferred').disabled = true;
+ showStatePanel('expired');
+}
+
+function showTimeout() {
+ stopPolling();
+ showStatePanel('timeout');
+}
+
+function showNotFound() {
+ stopPolling();
+ clearInterval(countdownInterval);
+ $('btn-transferred').disabled = true;
+ showStatePanel('not-found');
+}
+
+function retryPolling() {
+ _pollErrors = 0;
+ _pollStopped = false;
+ hideStatePanel();
+ checkOrderStatus();
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ setLang(detectLang());
+ initOrder();
+ syncStatusCardHeight();
+ window.addEventListener('resize', syncStatusCardHeight);
+});
+
diff --git a/src/static/reset.css b/src/static/reset.css
deleted file mode 100644
index 9b99f95..0000000
--- a/src/static/reset.css
+++ /dev/null
@@ -1,44 +0,0 @@
-html, body, div, span, applet, object, iframe,
-h1, h2, h3, h4, h5, h6, p, blockquote, pre,
-a, abbr, acronym, address, big, cite, code,
-del, dfn, em, img, ins, kbd, q, s, samp,
-small, strike, strong, sub, sup, tt, var,
-b, u, i, center,
-dl, dt, dd, ol, ul, li,
-fieldset, form, label, legend,
-table, caption, tbody, tfoot, thead, tr, th, td,
-article, aside, canvas, details, embed,
-figure, figcaption, footer, header, hgroup,
-menu, nav, output, ruby, section, summary,
-time, mark, audio, video {
- margin: 0;
- padding: 0;
- border: 0;
- font-size: 100%;
- font: inherit;
- vertical-align: baseline;
-}
-/* HTML5 display-role reset for older browsers */
-article, aside, details, figcaption, figure,
-footer, header, hgroup, menu, nav, section {
- display: block;
-}
-body {
- line-height: 1;
- background-color: #fafbfc;
-}
-ol, ul {
- list-style: none;
-}
-blockquote, q {
- quotes: none;
-}
-blockquote:before, blockquote:after,
-q:before, q:after {
- content: '';
- content: none;
-}
-table {
- border-collapse: collapse;
- border-spacing: 0;
-}