hyapp-h5/recharge/index.html
2026-06-11 01:01:40 +08:00

1556 lines
62 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>Recharge</title>
<link rel="stylesheet" href="../common/theme.css" />
<style>
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
width: 100%;
min-height: 100%;
margin: 0;
background: var(--hy-theme-bg);
color: var(--hy-theme-text);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Arial,
sans-serif;
}
body {
overflow-x: hidden;
}
button,
input {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
.page {
width: min(100%, 520px);
min-height: 100vh;
margin: 0 auto;
padding: max(18px, env(safe-area-inset-top)) 16px
max(24px, env(safe-area-inset-bottom));
}
.hero {
position: relative;
display: grid;
min-height: 136px;
align-items: end;
padding: 22px;
border: 1px solid var(--hy-theme-line);
border-radius: 22px;
background: var(--hy-theme-hero);
box-shadow: var(--hy-theme-shadow);
overflow: hidden;
}
.hero::after {
content: '';
position: absolute;
right: 22px;
top: 20px;
width: 68px;
height: 68px;
border-radius: 24px;
background:
linear-gradient(
145deg,
rgba(255, 255, 255, 0.86),
rgba(255, 255, 255, 0.18)
),
var(--hy-theme-primary);
box-shadow: 0 12px 28px rgba(125, 87, 199, 0.14);
transform: rotate(9deg);
}
.hero h1,
.hero p {
position: relative;
z-index: 2;
margin: 0;
}
.hero h1 {
max-width: 72%;
font-size: 30px;
line-height: 1.08;
letter-spacing: 0;
}
.hero p {
max-width: 86%;
margin-top: 9px;
color: var(--hy-theme-muted);
font-size: 14px;
line-height: 1.5;
}
.language-switcher {
position: absolute;
right: 14px;
top: 14px;
z-index: 10;
}
.language-button {
min-width: 56px;
height: 34px;
padding: 0 12px;
border: 1px solid var(--hy-theme-line);
border-radius: 17px;
background: var(--hy-theme-surface);
color: var(--hy-theme-text);
font-weight: 800;
box-shadow: var(--hy-theme-shadow);
}
.language-menu {
position: absolute;
right: 0;
top: 40px;
width: 128px;
padding: 6px;
border: 1px solid var(--hy-theme-line);
border-radius: 16px;
background: var(--hy-theme-surface);
box-shadow: var(--hy-theme-shadow);
}
.language-menu button {
width: 100%;
min-height: 34px;
border-radius: 12px;
background: transparent;
color: var(--hy-theme-text);
text-align: left;
}
[dir='rtl'] .language-menu button {
text-align: right;
}
.language-menu button.is-active {
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
font-weight: 800;
}
.section {
margin-top: 16px;
padding: 16px;
border: 1px solid var(--hy-theme-line);
border-radius: 20px;
background: var(--hy-theme-surface);
box-shadow: var(--hy-theme-shadow);
}
.view {
display: none;
}
.view.is-active {
display: block;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-title h2 {
margin: 0;
font-size: 17px;
letter-spacing: 0;
}
.section-title span {
color: var(--hy-theme-muted);
font-size: 12px;
font-weight: 700;
}
.account-search {
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
}
.input {
width: 100%;
height: 46px;
padding: 0 14px;
border: 1px solid var(--hy-theme-line);
border-radius: 14px;
background: var(--hy-theme-surface-soft);
color: var(--hy-theme-text);
outline: none;
}
.input:focus {
border-color: var(--hy-theme-primary-strong);
box-shadow: 0 0 0 3px rgba(197, 166, 246, 0.18);
}
.primary-button,
.secondary-button {
display: inline-flex;
min-height: 46px;
align-items: center;
justify-content: center;
padding: 0 16px;
border-radius: 14px;
font-weight: 850;
letter-spacing: 0;
}
.primary-button {
background: var(--hy-theme-button);
color: #fff;
box-shadow: var(--hy-theme-button-shadow);
}
.secondary-button {
border: 1px solid var(--hy-theme-line);
background: var(--hy-theme-surface-soft);
color: var(--hy-theme-primary-deep);
}
.primary-button:disabled,
.secondary-button:disabled {
background: var(--hy-theme-button-disabled);
box-shadow: var(--hy-theme-button-disabled-shadow);
color: var(--hy-theme-button-disabled-text);
cursor: default;
}
.account-card {
display: none;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 16px;
background: var(--hy-theme-primary-soft);
}
.account-card.is-visible {
display: flex;
}
.avatar {
width: 48px;
height: 48px;
flex: 0 0 auto;
overflow: hidden;
border-radius: 16px;
background: var(--hy-theme-primary);
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar span {
display: grid;
width: 100%;
height: 100%;
place-items: center;
color: var(--hy-theme-primary-deep);
font-size: 22px;
font-weight: 900;
}
.account-main {
min-width: 0;
flex: 1;
}
.account-name,
.account-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-name {
font-weight: 850;
}
.account-meta {
margin-top: 4px;
color: var(--hy-theme-muted);
font-size: 13px;
}
.role-pill {
min-width: 76px;
padding: 7px 10px;
border-radius: 999px;
background: var(--hy-theme-surface);
color: var(--hy-theme-primary-deep);
font-size: 12px;
font-weight: 850;
text-align: center;
}
.grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.choice {
position: relative;
min-height: 98px;
padding: 14px;
border: 1px solid var(--hy-theme-line);
border-radius: 18px;
background: var(--hy-theme-surface-soft);
color: var(--hy-theme-text);
text-align: left;
}
.choice.is-selected {
border-color: var(--hy-theme-primary-strong);
background: var(--hy-theme-primary-soft);
box-shadow: inset 0 0 0 1px var(--hy-theme-primary-strong);
}
.choice strong,
.choice span {
display: block;
}
.choice strong {
font-size: 18px;
line-height: 1.2;
}
.choice span {
margin-top: 7px;
color: var(--hy-theme-muted);
font-size: 13px;
line-height: 1.35;
}
.detail-top {
display: flex;
align-items: center;
gap: 12px;
margin-top: 2px;
}
.back-button {
width: 46px;
min-width: 46px;
padding: 0;
font-size: 24px;
line-height: 1;
}
.detail-title {
min-width: 0;
margin: 0;
font-size: 20px;
line-height: 1.25;
}
.selected-summary {
min-height: auto;
margin-top: 14px;
cursor: default;
}
.method-list {
display: grid;
gap: 10px;
}
.method {
display: grid;
min-height: 78px;
align-items: center;
gap: 12px;
grid-template-columns: 44px minmax(0, 1fr) auto;
padding: 12px;
border: 1px solid var(--hy-theme-line);
border-radius: 18px;
background: var(--hy-theme-surface-soft);
color: var(--hy-theme-text);
text-align: left;
}
.method.is-selected {
border-color: var(--hy-theme-primary-strong);
background: var(--hy-theme-primary-soft);
}
.method-icon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 15px;
background: var(--hy-theme-surface);
color: var(--hy-theme-primary-deep);
font-size: 14px;
font-weight: 900;
}
.method-main {
min-width: 0;
}
.method-name,
.method-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.method-name {
font-weight: 850;
}
.method-meta {
margin-top: 4px;
color: var(--hy-theme-muted);
font-size: 13px;
}
.method-amount {
color: var(--hy-theme-primary-deep);
font-size: 13px;
font-weight: 850;
white-space: nowrap;
}
.order-panel {
display: none;
gap: 12px;
}
.order-panel.is-visible {
display: grid;
}
.order-row {
display: flex;
min-width: 0;
justify-content: space-between;
gap: 12px;
color: var(--hy-theme-muted);
font-size: 13px;
}
.order-row strong {
min-width: 0;
overflow: hidden;
color: var(--hy-theme-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.address-box {
display: grid;
gap: 10px;
padding: 12px;
border-radius: 16px;
background: var(--hy-theme-primary-soft);
}
.address-value {
overflow-wrap: anywhere;
color: var(--hy-theme-text);
font-size: 13px;
font-weight: 800;
line-height: 1.5;
}
.tx-form {
display: grid;
gap: 10px;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
align-items: end;
padding: 16px;
background: rgba(34, 29, 46, 0.5);
}
.modal {
width: min(100%, 520px);
max-height: min(82vh, 680px);
margin: 0 auto;
overflow: auto;
border: 1px solid var(--hy-theme-line);
border-radius: 22px;
background: var(--hy-theme-surface);
box-shadow: 0 22px 54px rgba(40, 25, 72, 0.26);
}
.modal-header {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--hy-theme-line);
background: var(--hy-theme-surface);
}
.modal-header h2 {
margin: 0;
font-size: 18px;
letter-spacing: 0;
}
.modal-body {
padding: 16px;
}
.modal-actions {
display: grid;
gap: 10px;
margin-top: 12px;
}
.empty,
.status-line {
color: var(--hy-theme-muted);
font-size: 14px;
line-height: 1.5;
}
.status-line {
margin-top: 10px;
}
.hidden {
display: none !important;
}
@media (max-width: 390px) {
.page {
padding-right: 12px;
padding-left: 12px;
}
.hero h1 {
font-size: 27px;
}
.grid {
grid-template-columns: 1fr;
}
.account-search {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main class="page" aria-labelledby="page-title">
<div id="package-view" class="view is-active">
<section class="hero">
<div class="language-switcher">
<button
class="language-button"
type="button"
data-language-toggle
data-i18n-aria="recharge.changeLanguage"
aria-expanded="false"
>
<span data-current-lang>EN</span>
</button>
<div class="language-menu" data-language-menu hidden>
<button type="button" data-lang-option="en">
English
</button>
<button type="button" data-lang-option="zh">
中文
</button>
<button type="button" data-lang-option="ar">
العربية
</button>
<button type="button" data-lang-option="tr">
Türkçe
</button>
<button type="button" data-lang-option="es">
Español
</button>
<button type="button" data-lang-option="id">
Indonesia
</button>
</div>
</div>
<div>
<h1 id="page-title" data-i18n="recharge.pageTitle">
Recharge
</h1>
<p data-i18n="recharge.pageSubtitle">
Choose a coin package and pay with USDT-TRC20 or a
supported local payment method.
</p>
</div>
</section>
<section class="section" id="account-section">
<div class="section-title">
<h2 data-i18n="recharge.accountTitle">Account</h2>
<span id="auth-mode"></span>
</div>
<div id="account-search" class="account-search">
<input
id="display-user-id"
class="input"
autocomplete="off"
inputmode="numeric"
data-i18n-placeholder="recharge.userIdPlaceholder"
placeholder="Enter user ID"
/>
<button
id="search-account"
class="primary-button"
type="button"
data-i18n="recharge.searchAccount"
>
Confirm
</button>
</div>
<div id="account-card" class="account-card">
<div class="avatar" id="account-avatar"></div>
<div class="account-main">
<div class="account-name" id="account-name">-</div>
<div class="account-meta" id="account-meta">-</div>
</div>
<div class="role-pill" id="account-role">-</div>
</div>
<div
id="account-status"
class="status-line"
data-i18n="recharge.accountHint"
>
Confirm the account before selecting a package.
</div>
</section>
<section class="section">
<div class="section-title">
<h2 data-i18n="recharge.productTitle">Package</h2>
<span id="product-count"></span>
</div>
<div id="product-list" class="grid"></div>
<div
id="product-empty"
class="empty"
data-i18n="recharge.productEmpty"
>
No package is available for this account.
</div>
</section>
</div>
<div id="payment-view" class="view">
<div class="detail-top">
<button
id="back-to-products"
class="secondary-button back-button"
type="button"
data-i18n-aria="recharge.backToPackages"
aria-label="Back"
>
</button>
<h1 class="detail-title" data-i18n="recharge.paymentTitle">
Payment
</h1>
</div>
<section class="section">
<div class="section-title">
<h2 data-i18n="recharge.selectedPackage">
Selected package
</h2>
</div>
<div id="selected-product-card" class="choice selected-summary"></div>
</section>
<section class="section">
<div class="section-title">
<h2 data-i18n="recharge.paymentTitle">Payment</h2>
<span id="method-count"></span>
</div>
<div id="method-list" class="method-list"></div>
<div
id="method-empty"
class="empty"
data-i18n="recharge.noPaymentMethods"
>
No payment method is available.
</div>
<button
id="create-order"
class="primary-button"
type="button"
disabled
data-i18n="recharge.createOrder"
style="width: 100%; margin-top: 12px"
>
Create order
</button>
</section>
</div>
<div id="order-modal" class="modal-backdrop hidden" role="dialog" aria-modal="true">
<div class="modal">
<div class="modal-header">
<h2 data-i18n="recharge.paymentOperation">
Payment operation
</h2>
<button
id="close-order-modal"
class="secondary-button"
type="button"
data-i18n="recharge.close"
>
Close
</button>
</div>
<div class="modal-body">
<section id="order-panel" class="order-panel">
<div class="section-title">
<h2 data-i18n="recharge.orderTitle">Order</h2>
<span id="order-status"></span>
</div>
<div class="order-row">
<span data-i18n="recharge.orderId">Order ID</span>
<strong id="order-id">-</strong>
</div>
<div class="order-row">
<span data-i18n="recharge.orderStatus">Status</span>
<strong id="order-status-text">-</strong>
</div>
<div id="usdt-panel" class="hidden">
<div class="address-box">
<span data-i18n="recharge.usdtAddress">
USDT-TRC20 address
</span>
<div class="address-value" id="receive-address">-</div>
<button
id="copy-address"
class="secondary-button"
type="button"
data-i18n="recharge.copy"
>
Copy
</button>
</div>
<form id="tx-form" class="tx-form">
<input
id="tx-hash"
class="input"
autocomplete="off"
data-i18n-placeholder="recharge.txHashPlaceholder"
placeholder="Enter TXID after payment"
/>
<button
id="submit-tx"
class="primary-button"
type="submit"
data-i18n="recharge.submitTx"
>
Submit TXID
</button>
</form>
</div>
<div id="mifapay-panel" class="modal-actions hidden">
<button
id="open-payment-url"
class="primary-button"
type="button"
data-i18n="recharge.openPayment"
>
Open payment
</button>
</div>
<div id="order-message" class="status-line"></div>
</section>
</div>
</div>
</div>
</main>
<script src="../common/api.js"></script>
<script src="../common/params.js"></script>
<script src="../common/i18n.js"></script>
<script src="../common/toast.js"></script>
<script>
(function () {
var state = {
context: null,
options: null,
activeView: 'packages',
selectedProductID: 0,
selectedMethodKey: '',
order: null,
orderModalOpen: false,
pollingTimer: 0,
loading: false,
};
var nodes = {};
// 页面只依赖公共 api/params/i18n/themetoken、app_code、env 都交给 common 层解析。
// 初始化阶段只决定入口模式,带 token 直接查本人,不带 token 才允许输入 display_user_id。
function init() {
nodes = {
packageView: document.getElementById('package-view'),
paymentView: document.getElementById('payment-view'),
backToProducts:
document.getElementById('back-to-products'),
accountSearch:
document.getElementById('account-search'),
accountCard: document.getElementById('account-card'),
accountAvatar:
document.getElementById('account-avatar'),
accountName: document.getElementById('account-name'),
accountMeta: document.getElementById('account-meta'),
accountRole: document.getElementById('account-role'),
accountStatus:
document.getElementById('account-status'),
authMode: document.getElementById('auth-mode'),
displayUserID:
document.getElementById('display-user-id'),
searchAccount:
document.getElementById('search-account'),
productList: document.getElementById('product-list'),
productEmpty: document.getElementById('product-empty'),
productCount: document.getElementById('product-count'),
selectedProductCard: document.getElementById(
'selected-product-card'
),
methodList: document.getElementById('method-list'),
methodEmpty: document.getElementById('method-empty'),
methodCount: document.getElementById('method-count'),
createOrder: document.getElementById('create-order'),
orderModal: document.getElementById('order-modal'),
closeOrderModal:
document.getElementById('close-order-modal'),
orderPanel: document.getElementById('order-panel'),
orderID: document.getElementById('order-id'),
orderStatus: document.getElementById('order-status'),
orderStatusText:
document.getElementById('order-status-text'),
orderMessage: document.getElementById('order-message'),
usdtPanel: document.getElementById('usdt-panel'),
mifapayPanel:
document.getElementById('mifapay-panel'),
openPaymentURL:
document.getElementById('open-payment-url'),
receiveAddress:
document.getElementById('receive-address'),
copyAddress: document.getElementById('copy-address'),
txForm: document.getElementById('tx-form'),
txHash: document.getElementById('tx-hash'),
submitTx: document.getElementById('submit-tx'),
};
bindEvents();
var current = window.HyAppParams
? window.HyAppParams.parse()
: { raw: new URLSearchParams(window.location.search) };
var raw = current.raw || new URLSearchParams();
var displayUserID = raw.get('display_user_id') || '';
nodes.displayUserID.value = displayUserID;
if (current.token) {
// token 模式必须隐藏账号输入,目标用户只能由服务端鉴权结果决定,不能让前端改充值对象。
nodes.accountSearch.classList.add('hidden');
loadContext('');
} else if (displayUserID) {
// 链接预填 display_user_id 时自动确认一次,适合客服或分享充值链接直接带目标账号。
loadContext(displayUserID);
} else {
render();
}
}
// 所有用户动作集中绑定,异步入口统一进入 load/create/submit避免多个按钮各自拼接口参数。
function bindEvents() {
nodes.searchAccount.addEventListener('click', function () {
loadContext(nodes.displayUserID.value);
});
nodes.backToProducts.addEventListener('click', function () {
state.activeView = 'packages';
state.selectedMethodKey = '';
state.orderModalOpen = false;
render();
scrollToTop();
});
nodes.createOrder.addEventListener('click', createOrder);
nodes.closeOrderModal.addEventListener(
'click',
closeOrderModal
);
nodes.orderModal.addEventListener('click', function (event) {
if (event.target === nodes.orderModal) {
closeOrderModal();
}
});
nodes.openPaymentURL.addEventListener('click', function () {
if (state.order && state.order.pay_url) {
window.location.href = state.order.pay_url;
}
});
nodes.copyAddress.addEventListener('click', copyAddress);
nodes.txForm.addEventListener('submit', submitTx);
window.addEventListener('hyapp:i18n-ready', function () {
document.title = t('recharge.pageTitle', 'Recharge');
render();
});
}
// 账号确认是后续商品、支付方式、钱包入账目标的唯一前置事实。
// 无 token 时必须提供 display_user_id有 token 时 display_user_id 置空,后端只认 token 用户。
function loadContext(displayUserID) {
displayUserID = String(displayUserID || '').trim();
if (!window.HyAppAPI.getAccessToken() && !displayUserID) {
toast(t('recharge.accountRequired', 'Enter user ID'));
return;
}
setBusy(true);
setStatus(
nodes.accountStatus,
t('recharge.loading', 'Loading...')
);
window.HyAppAPI.recharge
.context(displayUserID)
.then(function (context) {
// context 只返回安全账号信息和 audience_type前端不读取手机号、邮箱、余额等敏感字段。
state.context = context;
return window.HyAppAPI.recharge.options(
context.auth_mode === 'token'
? ''
: context.account.display_user_id
);
})
.then(function (options) {
// 商品和支付方式必须跟随已确认账号重新加载,切换账号后清空旧选择和旧订单。
state.options = options;
state.activeView = 'packages';
state.selectedProductID = 0;
state.selectedMethodKey = '';
state.order = null;
state.orderModalOpen = false;
render();
})
.catch(function (error) {
toast(
error.message ||
t('recharge.loadFailed', 'Load failed')
);
setStatus(
nodes.accountStatus,
t('recharge.loadFailed', 'Load failed')
);
})
.finally(function () {
setBusy(false);
});
}
// render 只消费 state不发请求所有接口副作用集中在动作函数里方便排查状态流转。
function render() {
renderAccount();
renderProducts();
renderViews();
renderSelectedProduct();
renderMethods();
renderOrder();
}
// 账号卡只展示后端允许公开的账号确认信息;受众文案直接来自 audience_type。
function renderAccount() {
var account = state.context && state.context.account;
nodes.authMode.textContent = state.context
? state.context.auth_mode === 'token'
? 'Token'
: 'ID'
: '';
nodes.accountCard.classList.toggle('is-visible', !!account);
if (!account) return;
nodes.accountAvatar.innerHTML = account.avatar
? '<img src="' +
escapeAttr(account.avatar) +
'" alt="" />'
: '<span>' +
escapeHTML(firstLetter(account.username)) +
'</span>';
nodes.accountName.textContent =
account.username ||
account.display_user_id ||
t('recharge.accountConfirmed', 'Confirmed account');
nodes.accountMeta.textContent = [
account.display_user_id,
account.country_name || account.country_code,
account.region_name || account.region_code,
]
.filter(Boolean)
.join(' · ');
nodes.accountRole.textContent =
account.audience_type === 'coin_seller'
? t('recharge.coinSeller', 'Coin seller')
: t('recharge.ordinaryUser', 'User');
setStatus(
nodes.accountStatus,
t('recharge.accountConfirmed', 'Account confirmed')
);
}
// 商品列表由后端按 normal / coin_seller 和用户区域过滤,前端只负责单选交互。
function renderProducts() {
var products =
(state.options && state.options.products) || [];
nodes.productList.innerHTML = '';
nodes.productCount.textContent = products.length
? String(products.length)
: '';
nodes.productEmpty.classList.toggle(
'hidden',
products.length > 0
);
products.forEach(function (product) {
var button = document.createElement('button');
button.type = 'button';
button.className =
'choice' +
(product.product_id === state.selectedProductID
? ' is-selected'
: '');
button.innerHTML =
'<strong>' +
escapeHTML(formatCoins(product.coin_amount)) +
' ' +
escapeHTML(t('recharge.coins', 'coins')) +
'</strong><span>' +
escapeHTML(formatUSD(product)) +
'</span>';
button.addEventListener('click', function () {
state.selectedProductID = product.product_id;
// 用户点击档位后进入支付页,后续支付方式和订单都绑定这个档位快照。
// 切换档位必须清空支付方式和旧订单,避免把前一个档位的金额带入新订单。
state.activeView = 'payment';
state.selectedMethodKey = '';
state.order = null;
state.orderModalOpen = false;
render();
scrollToTop();
});
nodes.productList.appendChild(button);
});
}
// 两个页面状态只由 activeView 和当前商品决定;商品消失时自动退回档位页,避免空支付页。
function renderViews() {
var showPayment =
state.activeView === 'payment' && !!selectedProduct();
if (!showPayment && state.activeView === 'payment') {
state.activeView = 'packages';
}
nodes.packageView.classList.toggle(
'is-active',
state.activeView !== 'payment'
);
nodes.paymentView.classList.toggle(
'is-active',
state.activeView === 'payment'
);
}
// 支付页顶部固定展示选中的档位,用户进入下一步后还能明确当前订单金额和金币数。
function renderSelectedProduct() {
var product = selectedProduct();
if (!product) {
nodes.selectedProductCard.innerHTML = '';
return;
}
nodes.selectedProductCard.innerHTML =
'<strong>' +
escapeHTML(formatCoins(product.coin_amount)) +
' ' +
escapeHTML(t('recharge.coins', 'coins')) +
'</strong><span>' +
escapeHTML(formatUSD(product)) +
'</span>';
}
// 支付方式展示由当前商品驱动USDT 用订单美元金额MiFaPay 用后台汇率换算本币金额。
function renderMethods() {
var methods = availableMethods();
nodes.methodList.innerHTML = '';
nodes.methodCount.textContent = methods.length
? String(methods.length)
: '';
nodes.methodEmpty.classList.toggle(
'hidden',
methods.length > 0
);
nodes.methodEmpty.textContent = state.selectedProductID
? t('recharge.noPaymentMethods', 'No payment method')
: t(
'recharge.selectProductFirst',
'Select a package first'
);
methods.forEach(function (method) {
var button = document.createElement('button');
button.type = 'button';
button.className =
'method' +
(method.key === state.selectedMethodKey
? ' is-selected'
: '');
button.innerHTML =
'<span class="method-icon">' +
escapeHTML(method.icon) +
'</span><span class="method-main"><span class="method-name">' +
escapeHTML(method.name) +
'</span><span class="method-meta">' +
escapeHTML(method.meta) +
'</span></span><span class="method-amount">' +
escapeHTML(method.amount) +
'</span>';
button.addEventListener('click', function () {
state.selectedMethodKey = method.key;
renderMethods();
});
nodes.methodList.appendChild(button);
});
nodes.createOrder.disabled =
!state.selectedProductID ||
!state.selectedMethodKey ||
state.loading;
}
// 订单弹窗是支付后的唯一操作入口MiFaPay 展示打开支付按钮USDT 展示地址和 txid 表单。
// 弹窗关闭只影响展示,不会取消轮询;订单终态仍由服务端查询结果驱动。
function renderOrder() {
var order = state.order;
nodes.orderModal.classList.toggle(
'hidden',
!order || !state.orderModalOpen
);
nodes.orderPanel.classList.toggle('is-visible', !!order);
if (!order) return;
nodes.orderID.textContent = order.order_id || '-';
nodes.orderStatus.textContent = statusLabel(order.status);
nodes.orderStatusText.textContent = statusLabel(
order.status
);
nodes.usdtPanel.classList.toggle(
'hidden',
order.provider_code !== 'usdt_trc20'
);
nodes.mifapayPanel.classList.toggle(
'hidden',
order.provider_code !== 'mifapay' || !order.pay_url
);
if (order.provider_code === 'usdt_trc20') {
nodes.receiveAddress.textContent =
order.receive_address ||
(state.options &&
state.options.usdt_trc20_address) ||
'-';
}
if (order.status === 'credited') {
setStatus(
nodes.orderMessage,
t('recharge.paidSuccess', 'Payment completed')
);
} else if (order.status === 'failed') {
setStatus(
nodes.orderMessage,
order.failure_reason ||
t('recharge.orderFailed', 'Order failed')
);
} else if (order.provider_code === 'mifapay') {
setStatus(
nodes.orderMessage,
t(
'recharge.payOpenHint',
'Open the payment page and finish the payment.'
)
);
} else {
setStatus(
nodes.orderMessage,
t(
'recharge.usdtHint',
'Transfer USDT and submit the TXID.'
)
);
}
}
// 可用支付方式只在选中商品后生成,避免用户先选支付方式再切商品造成金额不一致。
// MiFaPay 方式必须是 activeUSDT 必须同时有开关和收款地址,缺地址时不展示。
function availableMethods() {
var product = selectedProduct();
if (!product) return [];
var methods = [];
if (
state.options &&
state.options.usdt_trc20_enabled &&
state.options.usdt_trc20_address
) {
methods.push({
key: 'usdt_trc20',
icon: 'USDT',
name: t('recharge.usdtTrc20', 'USDT-TRC20'),
meta: t('recharge.usdtNetwork', 'TRON network'),
amount: formatUSD(product),
providerCode: 'usdt_trc20',
});
}
((state.options && state.options.payment_methods) || [])
.filter(function (method) {
return method.status === 'active';
})
.forEach(function (method) {
methods.push({
key: 'mifapay:' + method.method_id,
icon: method.currency_code || 'PAY',
name:
method.method_name ||
method.pay_way ||
t(
'recharge.thirdPartyPayment',
'Local payment'
),
meta: [
method.provider_name,
method.country_name || method.country_code,
method.pay_type,
]
.filter(Boolean)
.join(' · '),
amount: convertedAmount(product, method),
providerCode: 'mifapay',
methodID: method.method_id,
});
});
return methods;
}
// 创建订单时只提交 product_id、provider 和 method_id金额、金币、币商/普通钱包都由服务端订单快照决定。
// 下单成功后统一打开操作弹窗MiFaPay 由用户点击 pay_urlUSDT 则在弹窗里复制地址并提交 txid。
function createOrder() {
var product = selectedProduct();
var method = selectedMethod();
if (!product || !method) return;
setBusy(true);
nodes.createOrder.textContent = t(
'recharge.creating',
'Creating...'
);
window.HyAppAPI.recharge
.createOrder({
display_user_id: targetDisplayUserID(),
product_id: product.product_id,
provider_code: method.providerCode,
payment_method_id: method.methodID || 0,
command_id: commandID(product, method),
return_url: window.location.href,
})
.then(function (payload) {
state.order = payload.order || payload;
state.orderModalOpen = true;
renderOrder();
startPolling();
})
.catch(function (error) {
toast(
error.message ||
t(
'recharge.orderCreateFailed',
'Create order failed'
)
);
})
.finally(function () {
setBusy(false);
nodes.createOrder.textContent = t(
'recharge.createOrder',
'Create order'
);
renderMethods();
});
}
function closeOrderModal() {
state.orderModalOpen = false;
renderOrder();
}
// USDT 模式只收 tx_hash链上合约、to 地址、金额、确认数和重复 txid 都由后端校验。
function submitTx(event) {
event.preventDefault();
if (!state.order || !nodes.txHash.value.trim()) return;
setBusy(true);
nodes.submitTx.textContent = t(
'recharge.submitted',
'Submitting...'
);
window.HyAppAPI.recharge
.submitTx(state.order.order_id, {
display_user_id: targetDisplayUserID(),
tx_hash: nodes.txHash.value.trim(),
})
.then(function (payload) {
state.order = payload.order || payload;
toast(t('recharge.txSubmitted', 'TXID submitted'));
renderOrder();
startPolling();
})
.catch(function (error) {
toast(
error.message ||
t('recharge.loadFailed', 'Load failed')
);
})
.finally(function () {
setBusy(false);
nodes.submitTx.textContent = t(
'recharge.submitTx',
'Submit TXID'
);
});
}
// 轮询只在订单存在时启动credited/failed 是终态,终态后停止,避免 H5 后台持续打接口。
function startPolling() {
window.clearInterval(state.pollingTimer);
if (!state.order || !state.order.order_id) return;
state.pollingTimer = window.setInterval(function () {
if (
!state.order ||
state.order.status === 'credited' ||
state.order.status === 'failed'
) {
window.clearInterval(state.pollingTimer);
return;
}
window.HyAppAPI.recharge
.getOrder(
state.order.order_id,
targetDisplayUserID()
)
.then(function (payload) {
state.order = payload.order || payload;
renderOrder();
})
.catch(function () {});
}, 5000);
}
// 复制地址不改变订单状态,只是降低 USDT 转账出错率;浏览器不支持 clipboard 时走 textarea 兜底。
function copyAddress() {
var address = nodes.receiveAddress.textContent || '';
if (!address || address === '-') return;
var copied = window.navigator.clipboard
? window.navigator.clipboard.writeText(address)
: fallbackCopy(address);
Promise.resolve(copied)
.then(function () {
toast(t('recharge.copied', 'Copied'));
})
.catch(function () {
toast(t('recharge.copyFailed', 'Copy failed'));
});
}
function fallbackCopy(text) {
var input = document.createElement('textarea');
input.value = text;
input.style.position = 'fixed';
input.style.opacity = '0';
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
}
// 选择对象每次从 options 当前快照查找,避免保存旧对象导致切账号后仍引用旧商品。
function selectedProduct() {
return (
((state.options && state.options.products) || []).find(
function (item) {
return (
item.product_id === state.selectedProductID
);
}
) || null
);
}
function selectedMethod() {
return (
availableMethods().find(function (item) {
return item.key === state.selectedMethodKey;
}) || null
);
}
// token 模式不回传 display_user_id防止前端把 token 用户和输入账号混用。
function targetDisplayUserID() {
if (
state.context &&
state.context.auth_mode !== 'token' &&
state.context.account
) {
return state.context.account.display_user_id || '';
}
return '';
}
// command_id 给服务端幂等使用,包含时间、商品和方式;真实订单号仍由后端生成。
function commandID(product, method) {
return [
'h5',
Date.now(),
product.product_id,
method.key.replace(/[^a-zA-Z0-9_:-]/g, ''),
].join(':');
}
// MiFaPay 国家方式按后台 USD->本币汇率展示;无有效汇率时回退 USD 展示但后端仍会拒绝不可用配置。
function convertedAmount(product, method) {
var usd = productUSD(product);
var rate = Number(method.usd_to_currency_rate || 0);
var currency = method.currency_code || 'USD';
var amount =
Number.isFinite(rate) && rate > 0 ? usd * rate : usd;
return (
amount.toFixed(currency === 'USD' ? 2 : 2) +
' ' +
currency
);
}
function productUSD(product) {
if (product.amount_minor) return product.amount_minor / 100;
if (product.amount_micro)
return product.amount_micro / 1000000;
return 0;
}
function formatUSD(product) {
return productUSD(product).toFixed(2) + ' USD';
}
function formatCoins(value) {
var numberValue = Number(value || 0);
return Number.isFinite(numberValue)
? numberValue.toLocaleString()
: '0';
}
function statusLabel(status) {
var key = 'recharge.status.' + String(status || 'pending');
return t(key, status || 'pending');
}
// loading 同时锁住确认账号和创建订单,避免用户在请求中切账号或重复下单。
function setBusy(loading) {
state.loading = loading;
nodes.searchAccount.disabled = loading;
nodes.createOrder.disabled =
loading ||
!state.selectedProductID ||
!state.selectedMethodKey;
}
function setStatus(node, message) {
node.textContent = message || '';
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function firstLetter(value) {
return (
String(value || 'H')
.trim()
.charAt(0) || 'H'
);
}
function toast(message) {
if (window.HyAppToast) window.HyAppToast.show(message);
}
function t(key, fallback) {
return window.HyAppI18n
? window.HyAppI18n.t(key, fallback)
: fallback || key;
}
function escapeHTML(value) {
return String(value || '').replace(
/[&<>"']/g,
function (char) {
return {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[char];
}
);
}
function escapeAttr(value) {
return escapeHTML(value).replace(/`/g, '&#96;');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>