369 lines
13 KiB
JavaScript
369 lines
13 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
var page = document.getElementById('coinSellerPage');
|
||
var region = document.getElementById('coinSellerRegion');
|
||
var loadingView = document.getElementById('sellerLoading');
|
||
var emptyView = document.getElementById('sellerEmpty');
|
||
var errorView = document.getElementById('sellerError');
|
||
var retryButton = document.getElementById('sellerRetry');
|
||
var skeletonList = document.getElementById('sellerSkeletonList');
|
||
var sellerList = document.getElementById('sellerList');
|
||
var sellerTemplate = document.getElementById('sellerCardTemplate');
|
||
var params =
|
||
(window.HyAppParams &&
|
||
window.HyAppParams.current &&
|
||
window.HyAppParams.current.raw) ||
|
||
new URLSearchParams(window.location.search);
|
||
var requestSequence = 0;
|
||
var currentItems = [];
|
||
var currentView = 'loading';
|
||
|
||
if (!page || !region || !sellerList || !sellerTemplate) return;
|
||
|
||
function t(key, fallback) {
|
||
return window.HyAppI18n && window.HyAppI18n.t
|
||
? window.HyAppI18n.t(key, fallback)
|
||
: fallback;
|
||
}
|
||
|
||
function format(message, values) {
|
||
return String(message || '').replace(
|
||
/\{([a-zA-Z0-9_]+)\}/g,
|
||
function (_, key) {
|
||
return Object.prototype.hasOwnProperty.call(values || {}, key)
|
||
? String(values[key])
|
||
: '';
|
||
}
|
||
);
|
||
}
|
||
|
||
function stringValue(value) {
|
||
return value === undefined || value === null
|
||
? ''
|
||
: String(value).trim();
|
||
}
|
||
|
||
function safeMediaURL(value) {
|
||
var raw = stringValue(value);
|
||
if (!raw) return '';
|
||
try {
|
||
var parsed = new URL(raw, window.location.href);
|
||
return parsed.protocol === 'https:' || parsed.protocol === 'http:'
|
||
? parsed.href
|
||
: '';
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function safeWhatsAppURL(value) {
|
||
var raw = stringValue(value);
|
||
if (!raw) return '';
|
||
try {
|
||
var parsed = new URL(raw, window.location.href);
|
||
if (parsed.protocol === 'whatsapp:') return parsed.href;
|
||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||
return '';
|
||
}
|
||
var host = parsed.hostname.toLowerCase();
|
||
return host === 'wa.me' || host === 'api.whatsapp.com'
|
||
? parsed.href
|
||
: '';
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function isVisualFixture() {
|
||
return params.get('mock') === '1';
|
||
}
|
||
|
||
function visualFixturePayload() {
|
||
var requestedCount = Number(params.get('mock_count') || 5);
|
||
var count = Number.isFinite(requestedCount)
|
||
? Math.max(0, Math.min(20, Math.floor(requestedCount)))
|
||
: 5;
|
||
var avatar = safeMediaURL(params.get('mock_avatar'));
|
||
var contactEnabled = params.get('mock_contact') !== '0';
|
||
var items = [];
|
||
|
||
// 该分支只服务显式 mock=1 的设计验收。生产请求失败时绝不进入这里,
|
||
// 否则示例币商会掩盖鉴权、区域或上游故障并被用户误认为真实联系人。
|
||
for (var index = 0; index < count; index += 1) {
|
||
items.push({
|
||
user_id: String(8800000 + index),
|
||
display_user_id: String(123456789 + index),
|
||
username: params.get('mock_name') || 'Namenamenamenamename',
|
||
avatar: avatar,
|
||
country_code: 'AE',
|
||
country_name: 'United Arab Emirates',
|
||
country_display_name: 'United Arab Emirates',
|
||
whatsapp_phone: contactEnabled ? '+971500000000' : '',
|
||
whatsapp_url: contactEnabled
|
||
? 'https://wa.me/971500000000'
|
||
: '',
|
||
});
|
||
}
|
||
return { items: items, total: items.length };
|
||
}
|
||
|
||
function normalizePayload(payload) {
|
||
if (!payload || !Array.isArray(payload.items)) {
|
||
throw new Error('coin_seller_response_invalid');
|
||
}
|
||
return payload.items.map(function (item) {
|
||
var source = item && typeof item === 'object' ? item : {};
|
||
return {
|
||
userId: stringValue(source.user_id),
|
||
displayUserId: stringValue(source.display_user_id),
|
||
username: stringValue(source.username),
|
||
avatar: stringValue(source.avatar),
|
||
countryCode: stringValue(source.country_code).toUpperCase(),
|
||
countryName: stringValue(
|
||
source.country_display_name || source.country_name
|
||
),
|
||
whatsappPhone: stringValue(source.whatsapp_phone),
|
||
whatsappURL: stringValue(source.whatsapp_url),
|
||
};
|
||
});
|
||
}
|
||
|
||
function sellerName(item) {
|
||
return (
|
||
item.username ||
|
||
item.displayUserId ||
|
||
item.userId ||
|
||
t('coin_seller_fami.unknown_seller', 'Seller')
|
||
);
|
||
}
|
||
|
||
function firstVisibleCharacter(value) {
|
||
var text = stringValue(value);
|
||
if (!text) return '';
|
||
if (window.Intl && window.Intl.Segmenter) {
|
||
var segments = new window.Intl.Segmenter(undefined, {
|
||
granularity: 'grapheme',
|
||
}).segment(text);
|
||
var first = segments[Symbol.iterator]().next();
|
||
return first.done ? '' : first.value.segment;
|
||
}
|
||
return Array.from(text)[0] || '';
|
||
}
|
||
|
||
function flagURL(countryCode) {
|
||
var code = stringValue(countryCode).toUpperCase();
|
||
if (!/^[A-Z]{2}$/.test(code)) return '';
|
||
if (code === 'AE') return './assets/fami/layers/flag-ae.webp';
|
||
// Figma 只提供 AE 叶子素材;其他国家必须按接口 country_code 请求真实国旗,
|
||
// 不能错误复用 AE 图。失败时保留 ISO code 作为有意义的单卡降级。
|
||
return 'https://flagcdn.com/w80/' + code.toLowerCase() + '.png';
|
||
}
|
||
|
||
function bindAvatar(card, item, name) {
|
||
var image = card.querySelector('.seller-avatar');
|
||
var fallback = card.querySelector('.seller-avatar-fallback');
|
||
var source = safeMediaURL(item.avatar);
|
||
fallback.textContent = firstVisibleCharacter(name);
|
||
image.alt = format(
|
||
t('coin_seller_fami.avatar_label', '{name} avatar'),
|
||
{ name: name }
|
||
);
|
||
image.addEventListener(
|
||
'load',
|
||
function () {
|
||
image.classList.add('is-loaded');
|
||
},
|
||
{ once: true }
|
||
);
|
||
image.addEventListener(
|
||
'error',
|
||
function () {
|
||
image.classList.remove('is-loaded');
|
||
image.removeAttribute('src');
|
||
},
|
||
{ once: true }
|
||
);
|
||
if (source) image.src = source;
|
||
}
|
||
|
||
function bindFlag(card, item) {
|
||
var image = card.querySelector('.seller-flag');
|
||
var fallback = card.querySelector('.seller-flag-code');
|
||
var code = item.countryCode;
|
||
var source = flagURL(code);
|
||
var country = item.countryName || code;
|
||
fallback.textContent = code;
|
||
image.alt = country
|
||
? format(t('coin_seller_fami.country_flag', '{country} flag'), {
|
||
country: country,
|
||
})
|
||
: '';
|
||
image.addEventListener(
|
||
'load',
|
||
function () {
|
||
image.classList.add('is-loaded');
|
||
},
|
||
{ once: true }
|
||
);
|
||
image.addEventListener(
|
||
'error',
|
||
function () {
|
||
image.classList.remove('is-loaded');
|
||
image.removeAttribute('src');
|
||
},
|
||
{ once: true }
|
||
);
|
||
if (source) image.src = source;
|
||
}
|
||
|
||
function bindContact(card, item, name) {
|
||
var contact = card.querySelector('.seller-contact');
|
||
var label = contact.querySelector('span');
|
||
var target = safeWhatsAppURL(item.whatsappURL);
|
||
var contactText = t('coin_seller_fami.contact', 'Contact');
|
||
label.textContent = contactText;
|
||
|
||
if (target) {
|
||
contact.href = target;
|
||
contact.target = '_blank';
|
||
contact.removeAttribute('aria-disabled');
|
||
contact.removeAttribute('tabindex');
|
||
contact.setAttribute(
|
||
'aria-label',
|
||
format(t('coin_seller_fami.contact_seller', 'Contact {name}'), {
|
||
name: name,
|
||
})
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 空联系方式来自服务端的稳定 schema。控件必须真实禁用,而不是仍然
|
||
// 导航到空页或由客户端根据电话号码猜测一个 WhatsApp 地址。
|
||
contact.removeAttribute('href');
|
||
contact.removeAttribute('target');
|
||
contact.setAttribute('aria-disabled', 'true');
|
||
contact.setAttribute('tabindex', '-1');
|
||
contact.setAttribute(
|
||
'aria-label',
|
||
t('coin_seller_fami.contact_unavailable', 'Contact unavailable')
|
||
);
|
||
contact.addEventListener('click', function (event) {
|
||
event.preventDefault();
|
||
});
|
||
}
|
||
|
||
function createSellerCard(item, index) {
|
||
var fragment = sellerTemplate.content.cloneNode(true);
|
||
var card = fragment.querySelector('.seller-card');
|
||
var name = sellerName(item);
|
||
var visibleID = item.displayUserId || item.userId;
|
||
|
||
card.dataset.userId = item.userId;
|
||
card.dataset.index = String(index);
|
||
card.querySelector('.seller-name').textContent = name;
|
||
card.querySelector('.seller-display-id').textContent = format(
|
||
t('coin_seller_fami.seller_id', 'ID: {id}'),
|
||
{ id: visibleID }
|
||
);
|
||
|
||
bindAvatar(card, item, name);
|
||
bindFlag(card, item);
|
||
bindContact(card, item, name);
|
||
return fragment;
|
||
}
|
||
|
||
function renderSellers(items) {
|
||
var fragment = document.createDocumentFragment();
|
||
sellerList.replaceChildren();
|
||
items.forEach(function (item, index) {
|
||
fragment.appendChild(createSellerCard(item, index));
|
||
});
|
||
sellerList.appendChild(fragment);
|
||
}
|
||
|
||
function createSkeletonCard() {
|
||
var item = document.createElement('li');
|
||
item.className = 'seller-skeleton-card';
|
||
item.innerHTML =
|
||
'<img class="seller-skeleton-skin" src="./assets/fami/layers/seller-card-skin.webp" alt="" draggable="false">' +
|
||
'<span class="seller-skeleton-avatar"></span>' +
|
||
'<span class="seller-skeleton-line seller-skeleton-name"></span>' +
|
||
'<span class="seller-skeleton-line seller-skeleton-id"></span>' +
|
||
'<span class="seller-skeleton-button"></span>';
|
||
return item;
|
||
}
|
||
|
||
function renderSkeletons() {
|
||
var fragment = document.createDocumentFragment();
|
||
for (var index = 0; index < 5; index += 1) {
|
||
fragment.appendChild(createSkeletonCard());
|
||
}
|
||
skeletonList.replaceChildren(fragment);
|
||
}
|
||
|
||
function setView(view) {
|
||
currentView = view;
|
||
loadingView.hidden = view !== 'loading';
|
||
emptyView.hidden = view !== 'empty';
|
||
errorView.hidden = view !== 'error';
|
||
sellerList.hidden = view !== 'list';
|
||
region.setAttribute('aria-busy', String(view === 'loading'));
|
||
}
|
||
|
||
function requestCoinSellers() {
|
||
if (isVisualFixture()) {
|
||
return Promise.resolve(visualFixturePayload());
|
||
}
|
||
if (
|
||
!window.HyAppAPI ||
|
||
!window.HyAppAPI.wallet ||
|
||
!window.HyAppAPI.wallet.listCoinSellers
|
||
) {
|
||
return Promise.reject(new Error('coin_seller_api_unavailable'));
|
||
}
|
||
return window.HyAppAPI.wallet.listCoinSellers();
|
||
}
|
||
|
||
function loadCoinSellers() {
|
||
var sequence = ++requestSequence;
|
||
currentItems = [];
|
||
setView('loading');
|
||
|
||
requestCoinSellers()
|
||
.then(function (payload) {
|
||
// Retry 或后续刷新可能先于旧请求完成;只允许最新序列提交 UI,
|
||
// 防止慢失败覆盖已经成功的新列表。
|
||
if (sequence !== requestSequence) return;
|
||
currentItems = normalizePayload(payload);
|
||
if (!currentItems.length) {
|
||
setView('empty');
|
||
return;
|
||
}
|
||
renderSellers(currentItems);
|
||
setView('list');
|
||
})
|
||
.catch(function (error) {
|
||
if (sequence !== requestSequence) return;
|
||
currentItems = [];
|
||
setView('error');
|
||
if (window.console && window.console.warn) {
|
||
window.console.warn('[coin-seller:fami]', error);
|
||
}
|
||
});
|
||
}
|
||
|
||
retryButton.addEventListener('click', loadCoinSellers);
|
||
|
||
window.addEventListener('hyapp:i18n-ready', function () {
|
||
// DOM 模板里的动态 ID 与可访问标签不由 data-i18n 自动插值;语言切换后
|
||
// 重新渲染当前成功列表即可保持视觉与读屏文案一致,不重新发业务请求。
|
||
if (currentView === 'list' && currentItems.length) {
|
||
renderSellers(currentItems);
|
||
}
|
||
});
|
||
|
||
renderSkeletons();
|
||
loadCoinSellers();
|
||
})();
|