496 lines
18 KiB
JavaScript
496 lines
18 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
var DESIGN_WIDTH = 1080;
|
||
var BASE_STAGE_HEIGHT = 3273;
|
||
var SECTION_STEP = 720;
|
||
var MAX_VIEWPORT_WIDTH = 430;
|
||
var STATUS_PATH = '/api/v1/activities/cumulative-recharge-reward';
|
||
|
||
var appViewport = document.getElementById('appViewport');
|
||
var stageWrap = document.getElementById('stageWrap');
|
||
var stage = document.getElementById('stage');
|
||
var countdown = document.getElementById('countdown');
|
||
var rewardSections = document.getElementById('rewardSections');
|
||
var profileName = document.getElementById('profileName');
|
||
var profileID = document.getElementById('profileID');
|
||
var profileRecharge = document.getElementById('profileRecharge');
|
||
var profileAvatar = document.getElementById('profileAvatar');
|
||
var pageStatus = document.getElementById('pageStatus');
|
||
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 currentData = null;
|
||
var designHeight = BASE_STAGE_HEIGHT;
|
||
var countdownEndAt = 0;
|
||
var timeOffsetMS = 0;
|
||
var countdownTimer = 0;
|
||
|
||
function queryParams() {
|
||
return new URLSearchParams(window.location.search || '');
|
||
}
|
||
|
||
function shouldUseMock(params) {
|
||
var value = String(params.get('mock') || '').toLowerCase();
|
||
// 设计数据只允许显式 mock=1/true;生产接口失败时不得回退到看似可用的虚假奖励。
|
||
return value === '1' || value === 'true';
|
||
}
|
||
|
||
function t(key, fallback) {
|
||
return window.HyAppI18n && typeof window.HyAppI18n.t === 'function'
|
||
? window.HyAppI18n.t(key, fallback)
|
||
: fallback || key;
|
||
}
|
||
|
||
function escapeHTML(value) {
|
||
return String(value == null ? '' : value)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function firstValue(source, keys, fallback) {
|
||
source = source && typeof source === 'object' ? source : {};
|
||
for (var index = 0; index < keys.length; index += 1) {
|
||
var value = source[keys[index]];
|
||
if (value !== undefined && value !== null && value !== '')
|
||
return value;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function numberValue(source, keys, fallback) {
|
||
var value = Number(firstValue(source, keys, fallback));
|
||
return Number.isFinite(value) ? value : Number(fallback || 0);
|
||
}
|
||
|
||
function wholeUSD(minor) {
|
||
var value = Math.max(0, Number(minor || 0)) / 100;
|
||
return value % 1 === 0
|
||
? String(value)
|
||
: value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||
}
|
||
|
||
function tierAmount(minor) {
|
||
return '$' + wholeUSD(minor);
|
||
}
|
||
|
||
function twoDigits(value) {
|
||
return String(Math.max(0, Math.floor(value))).padStart(2, '0');
|
||
}
|
||
|
||
function updateScale() {
|
||
var viewportWidth = Math.min(
|
||
window.innerWidth || 375,
|
||
MAX_VIEWPORT_WIDTH
|
||
);
|
||
var scale = viewportWidth / DESIGN_WIDTH;
|
||
var renderedHeight = designHeight * scale;
|
||
document.documentElement.style.setProperty(
|
||
'--fami-stage-scale',
|
||
String(scale)
|
||
);
|
||
document.documentElement.style.setProperty(
|
||
'--fami-stage-height',
|
||
designHeight + 'px'
|
||
);
|
||
document.documentElement.style.setProperty(
|
||
'--fami-background-bottom-top',
|
||
designHeight - 578 + 'px'
|
||
);
|
||
appViewport.style.minHeight = renderedHeight + 'px';
|
||
stageWrap.style.height = renderedHeight + 'px';
|
||
stage.style.height = designHeight + 'px';
|
||
}
|
||
|
||
function mockData(params) {
|
||
var rechargeValue = Number(params.get('recharge') || 5998);
|
||
var explicitEnd = Date.parse(
|
||
params.get('end_at') || params.get('endAt') || ''
|
||
);
|
||
var rewardItem = {
|
||
item_type: 'resource',
|
||
resource: {
|
||
name: 'Figma reward fixture',
|
||
preview_url: './fami/assets/mock-reward-822-2971.png',
|
||
},
|
||
};
|
||
return {
|
||
enabled: true,
|
||
user: {
|
||
username: params.get('name') || 'nanfangjian',
|
||
display_user_id:
|
||
params.get('uid') || params.get('id') || '12345678',
|
||
avatar:
|
||
params.get('avatar') ||
|
||
'./fami/assets/mock-avatar-822-2960.png',
|
||
},
|
||
period: {
|
||
// Figma 节点没有倒计时数字;只有显式 end_at 才展示动态数字,避免凭空猜一个验收值。
|
||
end_ms: Number.isFinite(explicitEnd) ? explicitEnd : 0,
|
||
},
|
||
progress: {
|
||
total_usd_minor: Math.max(0, Math.round(rechargeValue * 100)),
|
||
},
|
||
tiers: [
|
||
{
|
||
tier_id: 1,
|
||
threshold_usd_minor: 10000,
|
||
reached: true,
|
||
resource_group: { items: [rewardItem, rewardItem] },
|
||
},
|
||
{
|
||
tier_id: 2,
|
||
threshold_usd_minor: 10000,
|
||
reached: true,
|
||
resource_group: {
|
||
items: [rewardItem, rewardItem, rewardItem],
|
||
},
|
||
},
|
||
],
|
||
grants: [],
|
||
server_time_ms: Date.now(),
|
||
};
|
||
}
|
||
|
||
function resourcePreview(item) {
|
||
var resource = (item && item.resource) || {};
|
||
// preview_url 是 gateway 明确下发给静态 H5 的封面;asset_url 可能是动画,仅作为旧数据兜底。
|
||
return String(
|
||
firstValue(
|
||
resource,
|
||
[
|
||
'preview_url',
|
||
'previewUrl',
|
||
'asset_url',
|
||
'assetUrl',
|
||
'animation_url',
|
||
'animationUrl',
|
||
],
|
||
''
|
||
) || ''
|
||
);
|
||
}
|
||
|
||
function normalizeData(payload) {
|
||
payload = payload && payload.status ? payload.status : payload || {};
|
||
var config = payload.config || {};
|
||
var rawTiers = Array.isArray(payload.tiers)
|
||
? payload.tiers
|
||
: Array.isArray(config.tiers)
|
||
? config.tiers
|
||
: [];
|
||
var grants = Array.isArray(payload.grants) ? payload.grants : [];
|
||
var grantByTier = {};
|
||
grants.forEach(function (grant) {
|
||
var tierID = String(firstValue(grant, ['tier_id', 'tierId'], ''));
|
||
if (tierID) grantByTier[tierID] = grant;
|
||
});
|
||
|
||
var tiers = rawTiers
|
||
.slice()
|
||
.sort(function (a, b) {
|
||
return (
|
||
numberValue(a, ['sort_order', 'sortOrder'], 0) -
|
||
numberValue(b, ['sort_order', 'sortOrder'], 0)
|
||
);
|
||
})
|
||
.map(function (tier) {
|
||
var group = tier.resource_group || tier.resourceGroup || {};
|
||
var items = Array.isArray(group.items) ? group.items : [];
|
||
var tierID = String(
|
||
firstValue(tier, ['tier_id', 'tierId'], '')
|
||
);
|
||
var grant = tier.grant || grantByTier[tierID] || null;
|
||
return {
|
||
tierID: tierID,
|
||
thresholdUSDMinor: numberValue(
|
||
tier,
|
||
['threshold_usd_minor', 'thresholdUsdMinor'],
|
||
0
|
||
),
|
||
reached: Boolean(firstValue(tier, ['reached'], false)),
|
||
grant: grant,
|
||
cards: items.slice(0, 3).map(function (item) {
|
||
return {
|
||
name: firstValue(
|
||
(item && item.resource) || {},
|
||
['name'],
|
||
''
|
||
),
|
||
preview: resourcePreview(item),
|
||
};
|
||
}),
|
||
};
|
||
});
|
||
|
||
return {
|
||
enabled:
|
||
firstValue(
|
||
payload,
|
||
['enabled'],
|
||
firstValue(config, ['enabled'], true)
|
||
) !== false,
|
||
user: payload.user || {},
|
||
period: payload.period || {},
|
||
progress: payload.progress || {},
|
||
serverTimeMS: numberValue(
|
||
payload,
|
||
['server_time_ms', 'serverTimeMs'],
|
||
Date.now()
|
||
),
|
||
tiers: tiers,
|
||
};
|
||
}
|
||
|
||
function grantWasIssued(grant) {
|
||
if (!grant) return false;
|
||
var status = String(firstValue(grant, ['status'], '')).toLowerCase();
|
||
return (
|
||
['granted', 'success', 'succeeded', 'completed'].indexOf(status) >=
|
||
0
|
||
);
|
||
}
|
||
|
||
function cardPositions(count) {
|
||
if (count <= 1) return [425.5];
|
||
if (count === 2) return [201, 650];
|
||
return [160.5, 429.5, 698.5];
|
||
}
|
||
|
||
function renderCard(card, section, cardIndex, left) {
|
||
var media = card.preview
|
||
? '<img class="reward-media" src="' +
|
||
escapeHTML(card.preview) +
|
||
'" alt="' +
|
||
escapeHTML(card.name) +
|
||
'" />'
|
||
: '';
|
||
return [
|
||
'<article class="reward-card" style="left:',
|
||
left,
|
||
'px;top:',
|
||
section.cardTop,
|
||
'px" data-card-index="',
|
||
cardIndex,
|
||
'">',
|
||
'<img class="reward-card-frame" src="./fami/assets/reward-card-822-2969.png" alt="" data-node-id="822:2969" />',
|
||
media,
|
||
'<span class="reward-price">',
|
||
escapeHTML(tierAmount(section.thresholdUSDMinor)),
|
||
'</span>',
|
||
'</article>',
|
||
].join('');
|
||
}
|
||
|
||
function renderSections(tiers) {
|
||
designHeight = Math.max(
|
||
BASE_STAGE_HEIGHT,
|
||
BASE_STAGE_HEIGHT + Math.max(0, tiers.length - 2) * SECTION_STEP
|
||
);
|
||
rewardSections.innerHTML = tiers
|
||
.map(function (tier, sectionIndex) {
|
||
var section = {
|
||
thresholdUSDMinor: tier.thresholdUSDMinor,
|
||
cardTop: sectionIndex === 0 ? 293 : 278,
|
||
};
|
||
var positions = cardPositions(tier.cards.length);
|
||
var actionKey = grantWasIssued(tier.grant)
|
||
? 'rechargeReward.claimed'
|
||
: 'rechargeReward.receive';
|
||
var actionFallback = grantWasIssued(tier.grant)
|
||
? 'Claimed'
|
||
: 'Receive';
|
||
return [
|
||
'<section class="reward-section',
|
||
sectionIndex > 0 ? ' is-following' : '',
|
||
'" style="--section-top:',
|
||
1776 + sectionIndex * SECTION_STEP,
|
||
'px" data-tier-id="',
|
||
escapeHTML(tier.tierID),
|
||
'" data-node-id="',
|
||
sectionIndex === 0 ? '822:2965' : '822:2976',
|
||
'">',
|
||
'<img class="reward-panel" src="./fami/assets/reward-panel-822-2967.png" alt="" data-node-id="822:2967" />',
|
||
'<h2 class="reward-title">',
|
||
escapeHTML(
|
||
t(
|
||
'rechargeReward.rechargeTitle',
|
||
'Recharge {amount}'
|
||
).replace(
|
||
'{amount}',
|
||
tierAmount(tier.thresholdUSDMinor)
|
||
)
|
||
),
|
||
'</h2>',
|
||
tier.cards
|
||
.map(function (card, cardIndex) {
|
||
return renderCard(
|
||
card,
|
||
section,
|
||
cardIndex,
|
||
positions[cardIndex]
|
||
);
|
||
})
|
||
.join(''),
|
||
// 累充奖励由 wallet 充值事件自动发放,gateway 没有 claim RPC;设计中的 Receive 仅显示服务端状态,禁止伪造领取请求。
|
||
'<div class="reward-action" role="status" aria-label="',
|
||
escapeHTML(t(actionKey, actionFallback)),
|
||
'" data-node-id="',
|
||
sectionIndex === 0 ? '822:2991' : '822:2990',
|
||
'">',
|
||
'<img class="reward-action-skin" src="./fami/assets/receive-skin-822-2990.png" alt="" />',
|
||
'<span class="reward-action-label">',
|
||
escapeHTML(t(actionKey, actionFallback)),
|
||
'</span>',
|
||
'</div>',
|
||
'</section>',
|
||
].join('');
|
||
})
|
||
.join('');
|
||
updateScale();
|
||
}
|
||
|
||
function renderProfile(data) {
|
||
var user = data.user || {};
|
||
var displayID = firstValue(
|
||
user,
|
||
['display_user_id', 'displayUserId', 'user_id', 'userId'],
|
||
''
|
||
);
|
||
var name = firstValue(user, ['username', 'name'], '');
|
||
var avatar = String(
|
||
firstValue(user, ['avatar', 'avatar_url', 'avatarUrl'], '') || ''
|
||
);
|
||
var totalMinor = numberValue(
|
||
data.progress || {},
|
||
['total_usd_minor', 'totalUsdMinor'],
|
||
0
|
||
);
|
||
|
||
profileName.textContent = name;
|
||
profileID.textContent = displayID
|
||
? t('rechargeReward.idPrefix', 'ID:') + ' ' + displayID
|
||
: '';
|
||
profileRecharge.textContent =
|
||
t('rechargeReward.accumulatedRecharge', 'Accumulated Recharge:') +
|
||
wholeUSD(totalMinor);
|
||
if (avatar) {
|
||
profileAvatar.src = avatar;
|
||
profileAvatar.hidden = false;
|
||
} else {
|
||
profileAvatar.removeAttribute('src');
|
||
profileAvatar.hidden = true;
|
||
}
|
||
}
|
||
|
||
function updateCountdown() {
|
||
if (!countdownEndAt) {
|
||
countdown.classList.remove('has-values');
|
||
Object.keys(countNodes).forEach(function (key) {
|
||
countNodes[key].textContent = '';
|
||
});
|
||
return;
|
||
}
|
||
var remaining = Math.max(
|
||
0,
|
||
countdownEndAt - (Date.now() - timeOffsetMS)
|
||
);
|
||
var totalSeconds = Math.floor(remaining / 1000);
|
||
countNodes.days.textContent = twoDigits(totalSeconds / 86400);
|
||
countNodes.hours.textContent = twoDigits((totalSeconds % 86400) / 3600);
|
||
countNodes.minutes.textContent = twoDigits((totalSeconds % 3600) / 60);
|
||
countNodes.seconds.textContent = twoDigits(totalSeconds % 60);
|
||
countdown.classList.add('has-values');
|
||
}
|
||
|
||
function render(data) {
|
||
currentData = normalizeData(data);
|
||
renderProfile(currentData);
|
||
renderSections(currentData.tiers);
|
||
timeOffsetMS = Date.now() - currentData.serverTimeMS;
|
||
countdownEndAt = numberValue(
|
||
currentData.period,
|
||
['end_ms', 'endMs'],
|
||
0
|
||
);
|
||
updateCountdown();
|
||
if (countdownTimer) window.clearInterval(countdownTimer);
|
||
if (countdownEndAt)
|
||
countdownTimer = window.setInterval(updateCountdown, 1000);
|
||
pageStatus.hidden = true;
|
||
pageStatus.textContent = '';
|
||
stage.setAttribute('aria-busy', 'false');
|
||
}
|
||
|
||
function loadStatus(params) {
|
||
if (shouldUseMock(params)) return Promise.resolve(mockData(params));
|
||
if (!window.HyAppAPI || typeof window.HyAppAPI.get !== 'function') {
|
||
return Promise.reject(new Error('api_unavailable'));
|
||
}
|
||
return window.HyAppAPI.get(STATUS_PATH);
|
||
}
|
||
|
||
function showLoadError(error) {
|
||
console.warn('[recharge-reward/fami] status load failed', error);
|
||
currentData = null;
|
||
rewardSections.innerHTML = '';
|
||
profileName.textContent = '';
|
||
profileID.textContent = '';
|
||
profileRecharge.textContent = '';
|
||
profileAvatar.hidden = true;
|
||
pageStatus.textContent = t('rechargeReward.loadFailed', 'Load failed');
|
||
pageStatus.hidden = false;
|
||
stage.setAttribute('aria-busy', 'false');
|
||
}
|
||
|
||
function init() {
|
||
var params = queryParams();
|
||
document.title = t('rechargeReward.pageTitle', 'Recharge Reward');
|
||
updateScale();
|
||
loadStatus(params).then(render).catch(showLoadError);
|
||
|
||
window.addEventListener('resize', updateScale);
|
||
window.addEventListener('hyapp:i18n-ready', function () {
|
||
document.title = t('rechargeReward.pageTitle', 'Recharge Reward');
|
||
if (currentData) {
|
||
// 语言包只影响 DOM 文案;复用已归一化的业务状态,不能再次按原始 API 结构解析而丢失奖励卡。
|
||
renderProfile(currentData);
|
||
renderSections(currentData.tiers);
|
||
updateCountdown();
|
||
} else if (!pageStatus.hidden)
|
||
pageStatus.textContent = t(
|
||
'rechargeReward.loadFailed',
|
||
'Load failed'
|
||
);
|
||
});
|
||
|
||
// 远程头像或奖励封面失效时只隐藏业务媒体,装饰框和真实金额状态仍保留,避免回退到伪造素材。
|
||
stage.addEventListener(
|
||
'error',
|
||
function (event) {
|
||
if (
|
||
event.target === profileAvatar ||
|
||
(event.target &&
|
||
event.target.classList.contains('reward-media'))
|
||
) {
|
||
event.target.hidden = true;
|
||
}
|
||
},
|
||
true
|
||
);
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|