656 lines
21 KiB
JavaScript
656 lines
21 KiB
JavaScript
(function () {
|
||
var ASSET_BASE = './assets/recharge-activity/';
|
||
var viewport = document.getElementById('appViewport');
|
||
var page = document.getElementById('page');
|
||
var stage = document.querySelector('.stage');
|
||
var rewardSections = document.getElementById('rewardSections');
|
||
var bgBottom = document.querySelector('.bg-bottom');
|
||
var countNodes = {
|
||
days: document.querySelector('[data-count="days"]'),
|
||
hours: document.querySelector('[data-count="hours"]'),
|
||
minutes: document.querySelector('[data-count="minutes"]'),
|
||
seconds: document.querySelector('[data-count="seconds"]'),
|
||
};
|
||
|
||
var DESIGN_WIDTH = 1080;
|
||
var VIEWPORT_WIDTH = 375;
|
||
var MAX_PAGE_WIDTH = 430;
|
||
var DEFAULT_DESIGN_HEIGHT = 3273;
|
||
var designHeight = DEFAULT_DESIGN_HEIGHT;
|
||
var countdownEndAt = resolveCountdownEndAt();
|
||
var countdownTimer = 0;
|
||
var state = {
|
||
user: {
|
||
name: '',
|
||
id: '',
|
||
avatar: ASSET_BASE + 'profile-avatar.png',
|
||
accumulatedRecharge: 0,
|
||
},
|
||
rewards: [],
|
||
claiming: {},
|
||
loading: true,
|
||
error: '',
|
||
};
|
||
var fallbackCards = [
|
||
{
|
||
price: '$100',
|
||
icon: ASSET_BASE + 'reward-icon.png',
|
||
left: 111,
|
||
top: 293,
|
||
},
|
||
{
|
||
price: '$100',
|
||
icon: ASSET_BASE + 'reward-icon.png',
|
||
left: 560,
|
||
top: 293,
|
||
},
|
||
];
|
||
|
||
function getParams() {
|
||
return new URLSearchParams(window.location.search || '');
|
||
}
|
||
|
||
function t(key, fallback) {
|
||
return window.HyAppI18n && window.HyAppI18n.t
|
||
? window.HyAppI18n.t(key, fallback)
|
||
: fallback || key;
|
||
}
|
||
|
||
function escapeHTML(value) {
|
||
return String(value || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function twoDigits(value) {
|
||
return String(Math.max(0, value)).padStart(2, '0');
|
||
}
|
||
|
||
function showToast(message) {
|
||
if (window.HyAppToast && window.HyAppToast.show) {
|
||
window.HyAppToast.show(message);
|
||
return;
|
||
}
|
||
window.dispatchEvent(
|
||
new CustomEvent('hyapp:toast', { detail: { message: message } })
|
||
);
|
||
}
|
||
|
||
function numeric(value, fallback) {
|
||
var number = Number(value);
|
||
return Number.isFinite(number) ? number : fallback || 0;
|
||
}
|
||
|
||
function parseJSON(value) {
|
||
if (!value || typeof value !== 'string') return null;
|
||
try {
|
||
return JSON.parse(value);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function firstValue() {
|
||
for (var i = 0; i < arguments.length; i += 1) {
|
||
var value = arguments[i];
|
||
if (value !== undefined && value !== null && value !== '')
|
||
return value;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function formatUSD(value) {
|
||
var amount = numeric(value, 0);
|
||
return (
|
||
'$' +
|
||
amount.toLocaleString('en-US', {
|
||
maximumFractionDigits: 2,
|
||
minimumFractionDigits: amount % 1 === 0 ? 0 : 2,
|
||
})
|
||
);
|
||
}
|
||
|
||
function monthlyResetEndAt() {
|
||
var now = new Date();
|
||
return new Date(
|
||
now.getFullYear(),
|
||
now.getMonth() + 1,
|
||
1,
|
||
0,
|
||
0,
|
||
0,
|
||
0
|
||
).getTime();
|
||
}
|
||
|
||
function activityTimeEndAt(activityTime) {
|
||
var matches = String(activityTime || '').match(
|
||
/(\d{1,2})\s*\/\s*(\d{1,2})\s*$/
|
||
);
|
||
if (!matches) return 0;
|
||
|
||
var now = new Date();
|
||
var day = Number(matches[1]);
|
||
var month = Number(matches[2]);
|
||
if (!Number.isFinite(day) || !Number.isFinite(month)) return 0;
|
||
|
||
// 后端月累充返回的 activityTime 是 DD/MM - DD/MM,不带年份;结束日次日 00:00 就是月度清空点。
|
||
var endAt = new Date(
|
||
now.getFullYear(),
|
||
month - 1,
|
||
day + 1,
|
||
0,
|
||
0,
|
||
0,
|
||
0
|
||
).getTime();
|
||
return Number.isFinite(endAt) && endAt > Date.now() ? endAt : 0;
|
||
}
|
||
|
||
function resolveCountdownEndAt(statusData) {
|
||
var params = getParams();
|
||
var explicitEnd = Date.parse(
|
||
params.get('end_at') || params.get('endAt') || ''
|
||
);
|
||
if (Number.isFinite(explicitEnd) && explicitEnd > Date.now())
|
||
return explicitEnd;
|
||
|
||
var hours = Number(params.get('hours'));
|
||
if (Number.isFinite(hours) && hours > 0)
|
||
return Date.now() + hours * 60 * 60 * 1000;
|
||
|
||
return (
|
||
activityTimeEndAt(statusData && statusData.activityTime) ||
|
||
monthlyResetEndAt()
|
||
);
|
||
}
|
||
|
||
function getPageWidth() {
|
||
var viewportWidth =
|
||
window.innerWidth ||
|
||
document.documentElement.clientWidth ||
|
||
VIEWPORT_WIDTH;
|
||
return Math.min(Math.max(viewportWidth, 1), MAX_PAGE_WIDTH);
|
||
}
|
||
|
||
function updateScale() {
|
||
var pageWidth = getPageWidth();
|
||
var designScale = pageWidth / DESIGN_WIDTH;
|
||
var scaledHeight = Math.ceil(designHeight * designScale);
|
||
|
||
// 视觉稿仍以 1080 坐标布局,只把整张背景和内容按真实 WebView 宽度等比缩放,避免 412px 等机型露出页面两侧空白。
|
||
document.documentElement.style.setProperty(
|
||
'--page-width',
|
||
pageWidth + 'px'
|
||
);
|
||
document.documentElement.style.setProperty(
|
||
'--design-scale',
|
||
String(designScale)
|
||
);
|
||
viewport.style.minHeight = scaledHeight + 'px';
|
||
viewport.style.height = scaledHeight + 'px';
|
||
page.style.minHeight = scaledHeight + 'px';
|
||
page.style.height = scaledHeight + 'px';
|
||
document.body.style.minHeight =
|
||
Math.max(scaledHeight, window.innerHeight || 0) + 'px';
|
||
}
|
||
|
||
function updateCountdown() {
|
||
var diff = Math.max(0, countdownEndAt - Date.now());
|
||
var totalSeconds = Math.floor(diff / 1000);
|
||
var days = Math.floor(totalSeconds / 86400);
|
||
var hours = Math.floor((totalSeconds % 86400) / 3600);
|
||
var minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
var seconds = totalSeconds % 60;
|
||
|
||
countNodes.days.textContent = twoDigits(days);
|
||
countNodes.hours.textContent = twoDigits(hours);
|
||
countNodes.minutes.textContent = twoDigits(minutes);
|
||
countNodes.seconds.textContent = twoDigits(seconds);
|
||
}
|
||
|
||
function normalizeUser(profile, statusData) {
|
||
profile = profile || {};
|
||
var params = getParams();
|
||
return {
|
||
name: firstValue(
|
||
profile.userNickname,
|
||
profile.username,
|
||
profile.name,
|
||
params.get('name'),
|
||
'Aslan'
|
||
),
|
||
id: String(
|
||
firstValue(
|
||
profile.specialId,
|
||
profile.displayUserId,
|
||
profile.display_user_id,
|
||
profile.account,
|
||
profile.id,
|
||
profile.userId,
|
||
params.get('uid'),
|
||
params.get('id'),
|
||
''
|
||
)
|
||
),
|
||
avatar: firstValue(
|
||
profile.userAvatar,
|
||
profile.avatar,
|
||
profile.headImg,
|
||
profile.headImage,
|
||
params.get('avatar'),
|
||
ASSET_BASE + 'profile-avatar.png'
|
||
),
|
||
accumulatedRecharge: firstValue(
|
||
statusData && statusData.monthlyRecharge,
|
||
params.get('recharge'),
|
||
0
|
||
),
|
||
};
|
||
}
|
||
|
||
function rewardProps(resource) {
|
||
var group =
|
||
resource && resource.activityResource
|
||
? resource.activityResource.propsGroup || {}
|
||
: {};
|
||
return Array.isArray(group.activityRewardProps)
|
||
? group.activityRewardProps
|
||
: [];
|
||
}
|
||
|
||
function rule(resource) {
|
||
return resource && resource.activityResource
|
||
? resource.activityResource.rule || {}
|
||
: {};
|
||
}
|
||
|
||
function thresholdAmount(resource) {
|
||
var data = parseJSON(rule(resource).jsonData) || {};
|
||
return numeric(data.quantity, 0);
|
||
}
|
||
|
||
function cardLayout(count, index) {
|
||
if (count >= 4) {
|
||
return (
|
||
[
|
||
{ left: 70.5, top: 278 },
|
||
{ left: 292.5, top: 278 },
|
||
{ left: 514.5, top: 278 },
|
||
{ left: 736.5, top: 278 },
|
||
][index] || { left: 736.5, top: 278 }
|
||
);
|
||
}
|
||
if (count === 3) {
|
||
return (
|
||
[
|
||
{ left: 70.5, top: 278 },
|
||
{ left: 339.5, top: 278 },
|
||
{ left: 608.5, top: 278 },
|
||
][index] || { left: 608.5, top: 278 }
|
||
);
|
||
}
|
||
return (
|
||
[
|
||
{ left: 111, top: 293 },
|
||
{ left: 560, top: 293 },
|
||
][index] || { left: 560, top: 293 }
|
||
);
|
||
}
|
||
|
||
function normalizeCard(item, threshold, count, index) {
|
||
var pos = cardLayout(count, index);
|
||
return {
|
||
price:
|
||
item && item.amount
|
||
? formatUSD(item.amount)
|
||
: formatUSD(threshold),
|
||
icon: (item && item.cover) || ASSET_BASE + 'reward-icon.png',
|
||
left: pos.left,
|
||
top: pos.top,
|
||
};
|
||
}
|
||
|
||
function normalizeReward(resource, index) {
|
||
var currentRule = rule(resource);
|
||
var ruleId = String(currentRule.id || resource.ruleId || '');
|
||
var threshold = thresholdAmount(resource);
|
||
var props = rewardProps(resource).slice(0, 4);
|
||
var receiveStatus = String(resource.receiveStatus || '').toUpperCase();
|
||
var claimed = receiveStatus === 'RECEIVED';
|
||
var claimable = receiveStatus === 'NOT_RECEIVED';
|
||
var cardCount = props.length || fallbackCards.length;
|
||
|
||
return {
|
||
ruleId: ruleId,
|
||
amount: formatUSD(threshold),
|
||
top: 1776 + index * 690,
|
||
background:
|
||
index % 2 === 0
|
||
? ASSET_BASE + 'reward-section-a.png'
|
||
: ASSET_BASE + 'reward-section-b.png',
|
||
claim: {
|
||
text: claimed
|
||
? t('rechargeReward.claimed', 'Claimed')
|
||
: t('rechargeReward.claim', 'Claim'),
|
||
claimable: claimable,
|
||
claimed: claimed,
|
||
loading: Boolean(state.claiming[ruleId]),
|
||
},
|
||
cards: props.length
|
||
? props.map(function (item, cardIndex) {
|
||
return normalizeCard(
|
||
item,
|
||
threshold,
|
||
cardCount,
|
||
cardIndex
|
||
);
|
||
})
|
||
: fallbackCards.map(function (card) {
|
||
return Object.assign({}, card, {
|
||
price: formatUSD(threshold),
|
||
});
|
||
}),
|
||
};
|
||
}
|
||
|
||
function normalizeRewards(statusData) {
|
||
var resources =
|
||
statusData && Array.isArray(statusData.activityResources)
|
||
? statusData.activityResources
|
||
: [];
|
||
return resources
|
||
.map(function (item, index) {
|
||
return normalizeReward(item, index);
|
||
})
|
||
.filter(function (item) {
|
||
return item.ruleId;
|
||
});
|
||
}
|
||
|
||
function renderProfile(user) {
|
||
document.querySelector('[data-profile-avatar]').src =
|
||
user.avatar || ASSET_BASE + 'profile-avatar.png';
|
||
document.querySelector('[data-profile-name]').textContent =
|
||
user.name || 'Aslan';
|
||
document.querySelector('[data-profile-id]').textContent =
|
||
t('rechargeReward.idPrefix', 'ID:') + ' ' + (user.id || '-');
|
||
document.querySelector('[data-profile-recharge]').textContent =
|
||
t('rechargeReward.accumulatedRecharge', 'Accumulated Recharge:') +
|
||
formatUSD(user.accumulatedRecharge || 0);
|
||
}
|
||
|
||
function renderRewardCard(card) {
|
||
return [
|
||
'<div class="reward-card" style="left:',
|
||
card.left,
|
||
'px;top:',
|
||
card.top,
|
||
'px">',
|
||
'<img class="reward-frame" src="',
|
||
ASSET_BASE,
|
||
'reward-card-frame.png',
|
||
'" alt="" />',
|
||
'<img class="reward-icon" src="',
|
||
escapeHTML(card.icon),
|
||
'" alt="" />',
|
||
'<span class="reward-price">',
|
||
escapeHTML(card.price),
|
||
'</span>',
|
||
'</div>',
|
||
].join('');
|
||
}
|
||
|
||
function renderClaimButton(section) {
|
||
var claim = section.claim || {};
|
||
var stateClass = claim.claimed
|
||
? ' is-claimed'
|
||
: claim.claimable
|
||
? ' is-claimable'
|
||
: ' is-disabled';
|
||
var disabled = !claim.claimable || claim.claimed || claim.loading;
|
||
return [
|
||
'<button class="claim-button',
|
||
stateClass,
|
||
'" type="button" data-rule-id="',
|
||
escapeHTML(section.ruleId),
|
||
'"',
|
||
disabled ? ' disabled' : '',
|
||
'>',
|
||
'<img class="claim-button-bg" src="',
|
||
ASSET_BASE,
|
||
'receive-button-bg.png" alt="" />',
|
||
'<span>',
|
||
escapeHTML(
|
||
claim.loading
|
||
? t('rechargeReward.claiming', 'Claiming...')
|
||
: claim.text || t('rechargeReward.claim', 'Claim')
|
||
),
|
||
'</span>',
|
||
'</button>',
|
||
].join('');
|
||
}
|
||
|
||
function renderRewards(rewards) {
|
||
if (state.loading) {
|
||
rewardSections.innerHTML =
|
||
'<p class="reward-message">' +
|
||
escapeHTML(t('rechargeReward.loading', 'Loading...')) +
|
||
'</p>';
|
||
return;
|
||
}
|
||
if (state.error) {
|
||
rewardSections.innerHTML =
|
||
'<p class="reward-message is-error">' +
|
||
escapeHTML(state.error) +
|
||
'</p>';
|
||
return;
|
||
}
|
||
if (!rewards.length) {
|
||
rewardSections.innerHTML =
|
||
'<p class="reward-message">' +
|
||
escapeHTML(t('rechargeReward.noRewards', 'No rewards yet')) +
|
||
'</p>';
|
||
return;
|
||
}
|
||
|
||
rewardSections.innerHTML = rewards
|
||
.map(function (section) {
|
||
var title = t(
|
||
'rechargeReward.rechargeTitle',
|
||
'Recharge {amount}'
|
||
).replace('{amount}', section.amount);
|
||
return [
|
||
'<section class="reward-section" style="--section-top:',
|
||
section.top,
|
||
'px">',
|
||
'<img class="reward-section-bg" src="',
|
||
escapeHTML(section.background),
|
||
'" alt="" />',
|
||
'<div class="title-pill">',
|
||
'<span>',
|
||
escapeHTML(title),
|
||
'</span>',
|
||
'</div>',
|
||
section.cards
|
||
.map(function (card) {
|
||
return renderRewardCard(card);
|
||
})
|
||
.join(''),
|
||
renderClaimButton(section),
|
||
'</section>',
|
||
].join('');
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
function updateDesignHeight() {
|
||
var rewardCount = Math.max(state.rewards.length, 2);
|
||
designHeight = Math.max(
|
||
DEFAULT_DESIGN_HEIGHT,
|
||
1776 + rewardCount * 690 + 120
|
||
);
|
||
stage.style.height = designHeight + 'px';
|
||
if (bgBottom)
|
||
bgBottom.style.top = Math.max(2695, designHeight - 578) + 'px';
|
||
updateScale();
|
||
}
|
||
|
||
function renderPage() {
|
||
document.title = t('rechargeReward.pageTitle', 'Recharge Reward');
|
||
renderProfile(state.user);
|
||
updateDesignHeight();
|
||
renderRewards(state.rewards);
|
||
}
|
||
|
||
function mockData() {
|
||
var params = getParams();
|
||
var claimed = params.get('claimed') === '1';
|
||
return Promise.resolve({
|
||
profile: {
|
||
userNickname: params.get('name') || 'Aslan',
|
||
account: params.get('uid') || '12345678',
|
||
userAvatar:
|
||
params.get('avatar') || ASSET_BASE + 'profile-avatar.png',
|
||
},
|
||
status: {
|
||
monthlyRecharge: numeric(params.get('recharge'), 5998),
|
||
activityResources: [
|
||
{
|
||
receiveStatus: claimed ? 'RECEIVED' : 'NOT_RECEIVED',
|
||
activityResource: {
|
||
rule: {
|
||
id: '1000000000000000001',
|
||
jsonData: '{"quantity":100}',
|
||
},
|
||
propsGroup: { activityRewardProps: [] },
|
||
},
|
||
},
|
||
{
|
||
receiveStatus: 'INELIGIBLE',
|
||
activityResource: {
|
||
rule: {
|
||
id: '1000000000000000002',
|
||
jsonData: '{"quantity":200}',
|
||
},
|
||
propsGroup: { activityRewardProps: [] },
|
||
},
|
||
},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
|
||
function loadRemoteData() {
|
||
if (!window.AslanCommon) {
|
||
return Promise.reject(new Error('Aslan bridge unavailable'));
|
||
}
|
||
return Promise.all([
|
||
window.AslanCommon.user.profile().catch(function (error) {
|
||
console.warn('Aslan profile failed', error);
|
||
return null;
|
||
}),
|
||
window.AslanCommon.rechargeReward.status(),
|
||
]).then(function (result) {
|
||
return { profile: result[0], status: result[1] };
|
||
});
|
||
}
|
||
|
||
function loadData() {
|
||
state.loading = true;
|
||
state.error = '';
|
||
renderPage();
|
||
|
||
var params = getParams();
|
||
var loader =
|
||
params.get('mock') === '1' || params.get('mock') === 'true'
|
||
? mockData()
|
||
: loadRemoteData();
|
||
|
||
return loader
|
||
.then(function (payload) {
|
||
var statusData = payload.status || {};
|
||
countdownEndAt = resolveCountdownEndAt(statusData);
|
||
updateCountdown();
|
||
state.user = normalizeUser(payload.profile, statusData);
|
||
state.rewards = normalizeRewards(statusData);
|
||
state.loading = false;
|
||
state.error = '';
|
||
renderPage();
|
||
})
|
||
.catch(function (error) {
|
||
console.warn('Aslan recharge reward load failed', error);
|
||
state.loading = false;
|
||
state.error =
|
||
(error && error.message) ||
|
||
t('rechargeReward.loadFailed', 'Load failed');
|
||
renderPage();
|
||
});
|
||
}
|
||
|
||
function markClaimed(ruleId) {
|
||
state.rewards = state.rewards.map(function (item) {
|
||
if (item.ruleId !== ruleId) return item;
|
||
item.claim.claimed = true;
|
||
item.claim.claimable = false;
|
||
item.claim.loading = false;
|
||
item.claim.text = t('rechargeReward.claimed', 'Claimed');
|
||
return item;
|
||
});
|
||
}
|
||
|
||
function claimReward(ruleId) {
|
||
if (!ruleId || state.claiming[ruleId] || !window.AslanCommon) return;
|
||
state.claiming[ruleId] = true;
|
||
renderPage();
|
||
|
||
// 领取必须只提交后端规则 Long 的字符串形式,避免 JS Number 精度损失导致发错档位。
|
||
window.AslanCommon.rechargeReward
|
||
.claim(ruleId)
|
||
.then(function () {
|
||
delete state.claiming[ruleId];
|
||
markClaimed(ruleId);
|
||
renderPage();
|
||
showToast(t('rechargeReward.claimSuccess', 'Claimed'));
|
||
return loadData();
|
||
})
|
||
.catch(function (error) {
|
||
delete state.claiming[ruleId];
|
||
renderPage();
|
||
showToast(
|
||
(error && error.message) ||
|
||
t('rechargeReward.claimFailed', 'Claim failed')
|
||
);
|
||
});
|
||
}
|
||
|
||
function bindEvents() {
|
||
rewardSections.addEventListener('click', function (event) {
|
||
var button = event.target.closest('[data-rule-id]');
|
||
if (!button || button.disabled) return;
|
||
claimReward(button.getAttribute('data-rule-id'));
|
||
});
|
||
window.addEventListener('resize', updateScale);
|
||
window.addEventListener('orientationchange', updateScale);
|
||
window.addEventListener('hyapp:i18n-ready', function () {
|
||
renderPage();
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
updateScale();
|
||
updateCountdown();
|
||
renderPage();
|
||
bindEvents();
|
||
loadData();
|
||
|
||
if (countdownTimer) window.clearInterval(countdownTimer);
|
||
countdownTimer = window.setInterval(updateCountdown, 1000);
|
||
}
|
||
|
||
init();
|
||
})();
|