1275 lines
44 KiB
JavaScript
1275 lines
44 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
var RANKING_LIMIT = 30;
|
||
var DEFAULT_TIME_ZONE = 'Asia/Riyadh';
|
||
var state = {
|
||
detail: null,
|
||
activityId: '',
|
||
currentView: 'tasks',
|
||
currentPeriod: 'DAILY',
|
||
serverOffsetMs: 0,
|
||
countdownTimer: null,
|
||
rankings: {},
|
||
rankingLoading: {},
|
||
claimRequestIds: {},
|
||
claiming: {},
|
||
toastTimer: null,
|
||
};
|
||
|
||
function byId(id) {
|
||
return document.getElementById(id);
|
||
}
|
||
|
||
function all(selector, root) {
|
||
return Array.prototype.slice.call(
|
||
(root || document).querySelectorAll(selector)
|
||
);
|
||
}
|
||
|
||
function t(key, fallback, params) {
|
||
var translated = fallback || key;
|
||
if (window.HyAppI18n && window.HyAppI18n.t) {
|
||
translated = window.HyAppI18n.t(key, fallback || key);
|
||
}
|
||
// common/i18n.js 的 t 只负责取词,活动页在这里统一插值,避免把 params
|
||
// 误当 fallback 导致语言包尚未就绪时返回对象并触发运行时异常。
|
||
translated = text(translated || fallback || key);
|
||
Object.keys(params || {}).forEach(function (name) {
|
||
translated = translated
|
||
.split('{' + name + '}')
|
||
.join(text(params[name]));
|
||
});
|
||
return translated;
|
||
}
|
||
|
||
function asArray(value) {
|
||
return Array.isArray(value) ? value : [];
|
||
}
|
||
|
||
function firstDefined() {
|
||
for (var index = 0; index < arguments.length; index += 1) {
|
||
var value = arguments[index];
|
||
if (value !== undefined && value !== null && value !== '') {
|
||
return value;
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function toNumber(value, fallback) {
|
||
var parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : fallback || 0;
|
||
}
|
||
|
||
function toTime(value) {
|
||
if (value === undefined || value === null || value === '') return 0;
|
||
if (typeof value === 'number') {
|
||
return value > 0 && value < 100000000000 ? value * 1000 : value;
|
||
}
|
||
var numeric = Number(value);
|
||
if (Number.isFinite(numeric)) return toTime(numeric);
|
||
var parsed = Date.parse(String(value));
|
||
return Number.isFinite(parsed) ? parsed : 0;
|
||
}
|
||
|
||
function text(value) {
|
||
return value === undefined || value === null ? '' : String(value);
|
||
}
|
||
|
||
function createElement(tagName, className, content) {
|
||
var element = document.createElement(tagName);
|
||
if (className) element.className = className;
|
||
if (content !== undefined) element.textContent = text(content);
|
||
return element;
|
||
}
|
||
|
||
function formatNumber(value) {
|
||
var number = toNumber(value, 0);
|
||
try {
|
||
return new Intl.NumberFormat(
|
||
document.documentElement.lang || 'en',
|
||
{
|
||
notation:
|
||
Math.abs(number) >= 1000000 ? 'compact' : 'standard',
|
||
maximumFractionDigits: 1,
|
||
}
|
||
).format(number);
|
||
} catch (_) {
|
||
return String(number);
|
||
}
|
||
}
|
||
|
||
function safeImageURL(value) {
|
||
var candidate = text(value).trim();
|
||
if (!candidate) return '';
|
||
try {
|
||
var parsed = new URL(candidate, window.location.href);
|
||
var loopback = ['127.0.0.1', 'localhost', '[::1]'].indexOf(
|
||
parsed.hostname
|
||
);
|
||
// Production media must be HTTPS. HTTP is accepted only for loopback
|
||
// mock/local development so a remote response cannot inject mixed content.
|
||
if (parsed.protocol === 'https:' || loopback >= 0) {
|
||
return parsed.toString();
|
||
}
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function query(name) {
|
||
return new URLSearchParams(window.location.search || '').get(name);
|
||
}
|
||
|
||
function isMockMode() {
|
||
return query('mock') === '1';
|
||
}
|
||
|
||
function makeRequestId(scope) {
|
||
var prefix = 'gift-challenge:' + text(scope) + ':';
|
||
var random =
|
||
window.crypto && window.crypto.randomUUID
|
||
? window.crypto.randomUUID()
|
||
: Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||
return prefix + random;
|
||
}
|
||
|
||
function showToast(message) {
|
||
var toast = byId('toast');
|
||
if (!toast || !message) return;
|
||
window.clearTimeout(state.toastTimer);
|
||
toast.textContent = message;
|
||
toast.hidden = false;
|
||
state.toastTimer = window.setTimeout(function () {
|
||
toast.hidden = true;
|
||
}, 2600);
|
||
}
|
||
|
||
function getActivity(payload) {
|
||
return (payload && payload.activity) || payload || {};
|
||
}
|
||
|
||
function normalizeReward(reward, index) {
|
||
reward = reward || {};
|
||
return {
|
||
id: text(
|
||
firstDefined(
|
||
reward.id,
|
||
reward.rewardId,
|
||
reward.resourceId,
|
||
'reward-' + index
|
||
)
|
||
),
|
||
name: text(
|
||
firstDefined(
|
||
reward.name,
|
||
reward.rewardName,
|
||
reward.resourceName,
|
||
t('giftChallenge.reward', 'Reward')
|
||
)
|
||
),
|
||
quantity: Math.max(
|
||
1,
|
||
toNumber(
|
||
firstDefined(
|
||
reward.quantity,
|
||
reward.count,
|
||
reward.amount,
|
||
reward.duration,
|
||
1
|
||
),
|
||
1
|
||
)
|
||
),
|
||
imageUrl: safeImageURL(
|
||
firstDefined(
|
||
reward.imageUrl,
|
||
reward.iconUrl,
|
||
reward.resourceUrl,
|
||
reward.cover,
|
||
reward.sourceUrl,
|
||
reward.url
|
||
)
|
||
),
|
||
};
|
||
}
|
||
|
||
function normalizeTask(task, index) {
|
||
task = task || {};
|
||
var target = Math.max(
|
||
1,
|
||
toNumber(firstDefined(task.target, task.targetValue, 1), 1)
|
||
);
|
||
var progress = Math.max(
|
||
0,
|
||
toNumber(
|
||
firstDefined(
|
||
task.progress,
|
||
task.progressValue,
|
||
task.currentValue,
|
||
0
|
||
),
|
||
0
|
||
)
|
||
);
|
||
var rawStatus = text(
|
||
firstDefined(task.deliveryStatus, task.status, task.taskStatus)
|
||
).toUpperCase();
|
||
var completed =
|
||
task.completed === true ||
|
||
Boolean(task.completedTime) ||
|
||
rawStatus === 'COMPLETED' ||
|
||
progress >= target;
|
||
var claimed =
|
||
task.claimed === true ||
|
||
rawStatus === 'SUCCESS' ||
|
||
rawStatus === 'CLAIMED' ||
|
||
rawStatus === 'RECEIVED';
|
||
var claimable =
|
||
!claimed &&
|
||
(rawStatus === '' ||
|
||
rawStatus === 'NOT_CLAIMED' ||
|
||
rawStatus === 'CLAIMABLE' ||
|
||
rawStatus === 'COMPLETED') &&
|
||
(task.claimable === true || completed);
|
||
var type = text(
|
||
firstDefined(task.taskType, task.type, task.code)
|
||
).toUpperCase();
|
||
var code = text(
|
||
firstDefined(task.taskCode, task.code, 'TASK_' + (index + 1))
|
||
);
|
||
return {
|
||
code: code,
|
||
type: type,
|
||
title: text(
|
||
firstDefined(
|
||
task.taskTitle,
|
||
task.title,
|
||
task.taskName,
|
||
task.name
|
||
)
|
||
),
|
||
sortOrder: Math.max(
|
||
1,
|
||
toNumber(
|
||
firstDefined(task.sortOrder, task.sort, index + 1),
|
||
index + 1
|
||
)
|
||
),
|
||
target: target,
|
||
progress: progress,
|
||
completed: completed,
|
||
claimed: claimed,
|
||
claimable: claimable,
|
||
deliveryStatus: rawStatus,
|
||
enabled: task.enabled !== false,
|
||
rewards: asArray(
|
||
firstDefined(
|
||
task.rewards,
|
||
task.rewardItems,
|
||
task.rewardList,
|
||
[]
|
||
)
|
||
).map(normalizeReward),
|
||
};
|
||
}
|
||
|
||
function normalizeTasks(tasks) {
|
||
return asArray(tasks)
|
||
.map(normalizeTask)
|
||
.filter(function (task) {
|
||
return task.enabled;
|
||
})
|
||
.sort(function (left, right) {
|
||
return (
|
||
left.sortOrder - right.sortOrder ||
|
||
left.code.localeCompare(right.code)
|
||
);
|
||
});
|
||
}
|
||
|
||
function normalizeTier(tier, index) {
|
||
tier = tier || {};
|
||
var startRank = Math.max(
|
||
1,
|
||
toNumber(
|
||
firstDefined(
|
||
tier.startRank,
|
||
tier.rankStart,
|
||
tier.fromRank,
|
||
index + 1
|
||
),
|
||
index + 1
|
||
)
|
||
);
|
||
var endRank = Math.max(
|
||
startRank,
|
||
toNumber(
|
||
firstDefined(
|
||
tier.endRank,
|
||
tier.rankEnd,
|
||
tier.toRank,
|
||
startRank
|
||
),
|
||
startRank
|
||
)
|
||
);
|
||
return {
|
||
id: text(firstDefined(tier.id, tier.ruleId, index)),
|
||
period: text(
|
||
firstDefined(
|
||
tier.periodType,
|
||
tier.period,
|
||
tier.rankingPeriod,
|
||
'OVERALL'
|
||
)
|
||
).toUpperCase(),
|
||
startRank: startRank,
|
||
endRank: endRank,
|
||
rewards: asArray(
|
||
firstDefined(
|
||
tier.rewards,
|
||
tier.rewardItems,
|
||
tier.rewardList,
|
||
[]
|
||
)
|
||
).map(normalizeReward),
|
||
};
|
||
}
|
||
|
||
function normalizeRankRewards(payload) {
|
||
var raw = firstDefined(
|
||
payload && payload.rankRewards,
|
||
payload && payload.rankingRewards,
|
||
getActivity(payload).rankRewards,
|
||
[]
|
||
);
|
||
var result = { DAILY: [], OVERALL: [] };
|
||
if (Array.isArray(raw)) {
|
||
raw.map(normalizeTier).forEach(function (tier) {
|
||
var period = tier.period === 'DAILY' ? 'DAILY' : 'OVERALL';
|
||
result[period].push(tier);
|
||
});
|
||
} else if (raw && typeof raw === 'object') {
|
||
['DAILY', 'OVERALL'].forEach(function (period) {
|
||
result[period] = asArray(
|
||
raw[period] || raw[period.toLowerCase()]
|
||
).map(function (tier, index) {
|
||
return normalizeTier(
|
||
Object.assign({}, tier, { period: period }),
|
||
index
|
||
);
|
||
});
|
||
});
|
||
}
|
||
Object.keys(result).forEach(function (period) {
|
||
result[period].sort(function (left, right) {
|
||
return left.startRank - right.startRank;
|
||
});
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function normalizeDetail(payload) {
|
||
payload = payload || {};
|
||
var activity = getActivity(payload);
|
||
var tasks = firstDefined(
|
||
payload.tasks,
|
||
payload.taskList,
|
||
payload.me && payload.me.tasks,
|
||
activity.tasks,
|
||
[]
|
||
);
|
||
var serverTime = toTime(
|
||
firstDefined(payload.serverTime, activity.serverTime, Date.now())
|
||
);
|
||
return {
|
||
raw: payload,
|
||
activityId: text(
|
||
firstDefined(
|
||
activity.activityId,
|
||
activity.id,
|
||
payload.activityId,
|
||
payload.id
|
||
)
|
||
),
|
||
name: text(
|
||
firstDefined(
|
||
activity.activityName,
|
||
activity.name,
|
||
t('giftChallenge.title', 'Gift Challenge')
|
||
)
|
||
),
|
||
status: text(
|
||
firstDefined(payload.activityStatus, activity.status, 'ACTIVE')
|
||
).toUpperCase(),
|
||
timeZone: text(
|
||
firstDefined(
|
||
activity.timeZone,
|
||
payload.timeZone,
|
||
DEFAULT_TIME_ZONE
|
||
)
|
||
),
|
||
startTime: toTime(
|
||
firstDefined(activity.startTime, payload.startTime)
|
||
),
|
||
endTime: toTime(firstDefined(activity.endTime, payload.endTime)),
|
||
serverTime: serverTime || Date.now(),
|
||
statDate: text(
|
||
firstDefined(
|
||
payload.statDate,
|
||
payload.currentDate,
|
||
activity.statDate
|
||
)
|
||
),
|
||
tasks: normalizeTasks(tasks),
|
||
rankRewards: normalizeRankRewards(payload),
|
||
};
|
||
}
|
||
|
||
function mockDetail() {
|
||
var now = Date.now();
|
||
var rewardSet = [
|
||
{ id: 'gold', name: 'Gold', quantity: 99999 },
|
||
{ id: 'frame', name: 'Avatar Frame', quantity: 7 },
|
||
{ id: 'badge', name: 'Badge', quantity: 1 },
|
||
];
|
||
return {
|
||
activity: {
|
||
id: 'mock-gift-challenge',
|
||
activityName: 'Gift Challenge',
|
||
timeZone: DEFAULT_TIME_ZONE,
|
||
startTime: now - 86400000,
|
||
endTime: now + 6 * 86400000,
|
||
status: 'ACTIVE',
|
||
},
|
||
serverTime: now,
|
||
activityStatus: 'ACTIVE',
|
||
tasks: [
|
||
{
|
||
id: 'mock-task-1',
|
||
taskCode: 'DAILY_ENTRY',
|
||
taskType: 'ENTER_PAGE',
|
||
taskTitle: 'Visit the event page daily.',
|
||
targetValue: 1,
|
||
enabled: true,
|
||
sortOrder: 1,
|
||
rewardItems: rewardSet,
|
||
},
|
||
{
|
||
id: 'mock-task-2',
|
||
taskCode: 'SEND_GIFT_GOLD_1',
|
||
taskType: 'SEND_GIFT_GOLD',
|
||
taskTitle: 'Send 5,000 Gold worth of eligible gifts daily.',
|
||
targetValue: 5000,
|
||
enabled: true,
|
||
sortOrder: 2,
|
||
rewardItems: rewardSet,
|
||
},
|
||
{
|
||
id: 'mock-task-3',
|
||
taskCode: 'SEND_GIFT_GOLD_2',
|
||
taskType: 'SEND_GIFT_GOLD',
|
||
taskTitle:
|
||
'Send 10,000 Gold worth of eligible gifts daily.',
|
||
targetValue: 10000,
|
||
enabled: true,
|
||
sortOrder: 3,
|
||
rewardItems: rewardSet,
|
||
},
|
||
],
|
||
rankRewards: [
|
||
{
|
||
periodType: 'DAILY',
|
||
startRank: 1,
|
||
endRank: 1,
|
||
rewardItems: rewardSet,
|
||
},
|
||
{
|
||
periodType: 'DAILY',
|
||
startRank: 2,
|
||
endRank: 3,
|
||
rewardItems: rewardSet,
|
||
},
|
||
{
|
||
periodType: 'OVERALL',
|
||
startRank: 1,
|
||
endRank: 1,
|
||
rewardItems: rewardSet,
|
||
},
|
||
{
|
||
periodType: 'OVERALL',
|
||
startRank: 2,
|
||
endRank: 2,
|
||
rewardItems: rewardSet,
|
||
},
|
||
{
|
||
periodType: 'OVERALL',
|
||
startRank: 3,
|
||
endRank: 3,
|
||
rewardItems: rewardSet,
|
||
},
|
||
{
|
||
periodType: 'OVERALL',
|
||
startRank: 4,
|
||
endRank: 6,
|
||
rewardItems: rewardSet,
|
||
},
|
||
{
|
||
periodType: 'OVERALL',
|
||
startRank: 7,
|
||
endRank: 10,
|
||
rewardItems: rewardSet,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
function mockUserState() {
|
||
var rewardSet = mockDetail().rankRewards[0].rewardItems;
|
||
return {
|
||
activityId: 'mock-gift-challenge',
|
||
statDate: new Date().toISOString().slice(0, 10),
|
||
totalScore: 123456,
|
||
dailyScore: 123456,
|
||
overallRank: 4,
|
||
dailyRank: 4,
|
||
tasks: [
|
||
{
|
||
taskCode: 'DAILY_ENTRY',
|
||
taskType: 'ENTER_PAGE',
|
||
taskTitle: 'Visit the event page daily.',
|
||
sortOrder: 1,
|
||
targetValue: 1,
|
||
progressValue: 1,
|
||
completed: true,
|
||
deliveryStatus: 'NOT_CLAIMED',
|
||
rewardItems: rewardSet,
|
||
},
|
||
{
|
||
taskCode: 'SEND_GIFT_GOLD_1',
|
||
taskType: 'SEND_GIFT_GOLD',
|
||
taskTitle: 'Send 5,000 Gold worth of eligible gifts daily.',
|
||
sortOrder: 2,
|
||
targetValue: 5000,
|
||
progressValue: 2400,
|
||
completed: false,
|
||
deliveryStatus: 'NOT_CLAIMED',
|
||
rewardItems: rewardSet,
|
||
},
|
||
{
|
||
taskCode: 'SEND_GIFT_GOLD_2',
|
||
taskType: 'SEND_GIFT_GOLD',
|
||
taskTitle:
|
||
'Send 10,000 Gold worth of eligible gifts daily.',
|
||
sortOrder: 3,
|
||
targetValue: 10000,
|
||
progressValue: 10000,
|
||
completed: true,
|
||
deliveryStatus: 'SUCCESS',
|
||
rewardItems: rewardSet,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
function mockRanking(period) {
|
||
var entries = Array.from({ length: 10 }, function (_, index) {
|
||
return {
|
||
rank: index + 1,
|
||
userId: 'mock-' + (index + 1),
|
||
account: String(1635468 + index),
|
||
nickname: ['Keven', 'Ayla', 'Leo', 'Nora'][index % 4],
|
||
score: (123456 - index * 8173) * (period === 'OVERALL' ? 6 : 1),
|
||
me: index === 3,
|
||
};
|
||
});
|
||
return {
|
||
entries: entries,
|
||
my: Object.assign({}, entries[3], { me: true }),
|
||
periodType: period,
|
||
settled: period === 'DAILY',
|
||
};
|
||
}
|
||
|
||
function mockApi() {
|
||
return {
|
||
current: function () {
|
||
return Promise.resolve(mockDetail());
|
||
},
|
||
enter: function () {
|
||
return Promise.resolve(mockUserState());
|
||
},
|
||
ranking: function (_, period) {
|
||
return Promise.resolve(mockRanking(period));
|
||
},
|
||
claimTask: function (_, taskCode) {
|
||
var userState = mockUserState();
|
||
var task = userState.tasks.find(function (item) {
|
||
return item.taskCode === taskCode;
|
||
});
|
||
if (task) task.deliveryStatus = 'SUCCESS';
|
||
return Promise.resolve(userState);
|
||
},
|
||
};
|
||
}
|
||
|
||
function api() {
|
||
if (isMockMode()) return mockApi();
|
||
if (window.AslanCommon && window.AslanCommon.giftChallenge) {
|
||
return window.AslanCommon.giftChallenge;
|
||
}
|
||
throw new Error('gift_challenge_api_unavailable');
|
||
}
|
||
|
||
function taskTitle(task) {
|
||
if (task.type === 'ENTER_PAGE' || task.code === 'ENTER_PAGE') {
|
||
return t('giftChallenge.taskEnter', 'Visit the event page daily.');
|
||
}
|
||
if (task.type === 'SEND_GIFT_GOLD') {
|
||
return t(
|
||
'giftChallenge.taskSendGold',
|
||
'Send {amount} Gold worth of eligible gifts daily.',
|
||
{ amount: formatNumber(task.target) }
|
||
).replace('{amount}', formatNumber(task.target));
|
||
}
|
||
// 固定任务类型使用 common 四语文案;后台 taskTitle 只作为未知扩展类型兜底,
|
||
// 否则单一配置字符串会覆盖 ar/tr/es 并破坏页面多语言能力。
|
||
if (task.title) return task.title;
|
||
return t('giftChallenge.untitledTask', 'Daily task');
|
||
}
|
||
|
||
function rewardSlot(reward) {
|
||
reward = normalizeReward(reward, 0);
|
||
var slot = createElement('div', 'reward-slot');
|
||
slot.title = reward.name;
|
||
if (reward.imageUrl) {
|
||
var image = document.createElement('img');
|
||
image.src = reward.imageUrl;
|
||
image.alt = reward.name;
|
||
image.loading = 'lazy';
|
||
image.referrerPolicy = 'no-referrer';
|
||
slot.appendChild(image);
|
||
} else {
|
||
var fallback = createElement('span', 'reward-placeholder', '✦');
|
||
fallback.setAttribute('aria-label', reward.name);
|
||
slot.appendChild(fallback);
|
||
}
|
||
slot.appendChild(
|
||
createElement('span', 'reward-count', formatNumber(reward.quantity))
|
||
);
|
||
return slot;
|
||
}
|
||
|
||
function renderTasks() {
|
||
var list = byId('taskList');
|
||
var empty = byId('taskEmpty');
|
||
if (!list || !state.detail) return;
|
||
list.replaceChildren();
|
||
var tasks = state.detail.tasks;
|
||
empty.hidden = tasks.length > 0;
|
||
tasks.forEach(function (task) {
|
||
var card = createElement('article', 'task-card');
|
||
card.dataset.taskCode = task.code;
|
||
var heading = createElement('div', 'task-heading');
|
||
var titleWrap = createElement('div', 'task-title-wrap');
|
||
titleWrap.appendChild(createElement('h3', '', taskTitle(task)));
|
||
titleWrap.appendChild(
|
||
createElement(
|
||
'span',
|
||
'task-progress',
|
||
formatNumber(Math.min(task.progress, task.target)) +
|
||
' / ' +
|
||
formatNumber(task.target)
|
||
)
|
||
);
|
||
heading.appendChild(titleWrap);
|
||
|
||
var action = createElement('button', 'task-action');
|
||
action.type = 'button';
|
||
action.dataset.taskCode = task.code;
|
||
if (state.claiming[task.code]) {
|
||
action.classList.add('is-loading');
|
||
action.disabled = true;
|
||
action.textContent = t('giftChallenge.claiming', 'Claiming');
|
||
} else if (task.claimed) {
|
||
action.classList.add('is-claimed');
|
||
action.disabled = true;
|
||
action.textContent = t('giftChallenge.claimed', 'Claimed');
|
||
} else if (task.claimable) {
|
||
action.dataset.action = 'claim';
|
||
action.textContent = t('giftChallenge.receive', 'Receive');
|
||
} else if (task.completed) {
|
||
// PENDING/PROCESSING 仍可能在下游发放,FAILED/UNKNOWN 则必须由运营
|
||
// 核账;两类状态都禁用按钮,避免 H5 盲目重发已经到账的奖励。
|
||
action.classList.add('is-claimed');
|
||
action.disabled = true;
|
||
action.textContent =
|
||
task.deliveryStatus === 'PENDING' ||
|
||
task.deliveryStatus === 'PROCESSING'
|
||
? t('giftChallenge.processing', 'Processing')
|
||
: t('giftChallenge.underReview', 'Under review');
|
||
} else {
|
||
action.dataset.action = 'go';
|
||
action.textContent = t('giftChallenge.go', 'Go');
|
||
}
|
||
heading.appendChild(action);
|
||
card.appendChild(heading);
|
||
|
||
var strip = createElement('div', 'reward-strip');
|
||
task.rewards.slice(0, 3).forEach(function (reward) {
|
||
strip.appendChild(rewardSlot(reward));
|
||
});
|
||
if (!task.rewards.length) {
|
||
strip.appendChild(
|
||
rewardSlot({
|
||
name: t('giftChallenge.reward', 'Reward'),
|
||
quantity: 1,
|
||
})
|
||
);
|
||
}
|
||
card.appendChild(strip);
|
||
list.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function rankingEntries(payload) {
|
||
return asArray(
|
||
firstDefined(
|
||
payload && payload.entries,
|
||
payload && payload.rankings,
|
||
payload && payload.list,
|
||
payload && payload.records,
|
||
[]
|
||
)
|
||
);
|
||
}
|
||
|
||
function normalizeRankEntry(entry, index) {
|
||
entry = entry || {};
|
||
var privacyHidden =
|
||
entry.hidden === true ||
|
||
entry.anonymous === true ||
|
||
text(entry.privacyMode).toUpperCase() === 'INVISIBLE';
|
||
return {
|
||
rank: Math.max(
|
||
1,
|
||
toNumber(
|
||
firstDefined(entry.rank, entry.ranking, index + 1),
|
||
index + 1
|
||
)
|
||
),
|
||
userId: text(firstDefined(entry.userId, entry.uid, entry.id)),
|
||
displayId: text(
|
||
firstDefined(
|
||
entry.displayId,
|
||
entry.userDisplayId,
|
||
entry.shortId,
|
||
entry.account,
|
||
entry.uid
|
||
)
|
||
),
|
||
nickname: privacyHidden
|
||
? t('giftChallenge.anonymous', 'Anonymous')
|
||
: text(
|
||
firstDefined(
|
||
entry.nickname,
|
||
entry.userName,
|
||
entry.name,
|
||
t('giftChallenge.anonymous', 'Anonymous')
|
||
)
|
||
),
|
||
avatarUrl: privacyHidden
|
||
? ''
|
||
: safeImageURL(
|
||
firstDefined(
|
||
entry.avatarUrl,
|
||
entry.avatar,
|
||
entry.headImage
|
||
)
|
||
),
|
||
score: Math.max(
|
||
0,
|
||
toNumber(
|
||
firstDefined(entry.score, entry.points, entry.goldAmount),
|
||
0
|
||
)
|
||
),
|
||
isSelf: entry.isSelf === true || entry.me === true,
|
||
};
|
||
}
|
||
|
||
function avatar(entry, className) {
|
||
var wrap = createElement('span', className || 'rank-avatar-wrap');
|
||
if (entry.avatarUrl) {
|
||
var image = document.createElement('img');
|
||
image.className =
|
||
className === 'podium-avatar-wrap'
|
||
? 'podium-avatar'
|
||
: 'rank-avatar';
|
||
image.src = entry.avatarUrl;
|
||
image.alt = entry.nickname;
|
||
image.loading = 'lazy';
|
||
image.referrerPolicy = 'no-referrer';
|
||
wrap.appendChild(image);
|
||
} else {
|
||
wrap.appendChild(
|
||
createElement(
|
||
'span',
|
||
'avatar-fallback',
|
||
(entry.nickname || '?').slice(0, 1).toUpperCase()
|
||
)
|
||
);
|
||
}
|
||
return wrap;
|
||
}
|
||
|
||
function scoreNode(score, className) {
|
||
var wrapper = createElement('span', className || 'rank-score');
|
||
var coin = document.createElement('img');
|
||
coin.src = './aslan/assets/layers/coin.png';
|
||
coin.alt = '';
|
||
wrapper.appendChild(coin);
|
||
wrapper.appendChild(createElement('span', '', formatNumber(score)));
|
||
return wrapper;
|
||
}
|
||
|
||
function rankRow(entry) {
|
||
var item = createElement('li', 'rank-entry');
|
||
item.appendChild(createElement('span', 'rank-number', entry.rank));
|
||
item.appendChild(avatar(entry, 'rank-avatar-wrap'));
|
||
var user = createElement('span', 'rank-user');
|
||
user.appendChild(createElement('strong', '', entry.nickname));
|
||
user.appendChild(
|
||
createElement(
|
||
'small',
|
||
'',
|
||
t('giftChallenge.idPrefix', 'ID:') +
|
||
' ' +
|
||
(entry.displayId || '--')
|
||
)
|
||
);
|
||
item.appendChild(user);
|
||
item.appendChild(scoreNode(entry.score, 'rank-score'));
|
||
return item;
|
||
}
|
||
|
||
function podiumEntry(entry) {
|
||
var item = createElement('li', 'podium-entry');
|
||
var avatarWrap = avatar(entry, 'podium-avatar-wrap');
|
||
avatarWrap.dataset.rankLabel = 'TOP' + entry.rank;
|
||
item.appendChild(avatarWrap);
|
||
item.appendChild(createElement('span', 'podium-name', entry.nickname));
|
||
item.appendChild(scoreNode(entry.score, 'podium-score'));
|
||
return item;
|
||
}
|
||
|
||
function renderRanking(payload) {
|
||
var entries = rankingEntries(payload).map(normalizeRankEntry);
|
||
entries.sort(function (left, right) {
|
||
return left.rank - right.rank;
|
||
});
|
||
var podium = byId('podium');
|
||
var list = byId('rankList');
|
||
var empty = byId('rankEmpty');
|
||
podium.replaceChildren();
|
||
list.replaceChildren();
|
||
entries.slice(0, 3).forEach(function (entry) {
|
||
podium.appendChild(podiumEntry(entry));
|
||
});
|
||
entries.slice(3).forEach(function (entry) {
|
||
list.appendChild(rankRow(entry));
|
||
});
|
||
empty.hidden = entries.length > 0;
|
||
|
||
var self = firstDefined(
|
||
payload && payload.my,
|
||
payload && payload.myRanking,
|
||
payload && payload.myRank,
|
||
payload && payload.me
|
||
);
|
||
var selfRank = byId('selfRank');
|
||
selfRank.replaceChildren();
|
||
if (self && typeof self === 'object') {
|
||
var normalizedSelf = normalizeRankEntry(self, entries.length);
|
||
normalizedSelf.isSelf = true;
|
||
var row = rankRow(normalizedSelf);
|
||
while (row.firstChild) selfRank.appendChild(row.firstChild);
|
||
selfRank.hidden = false;
|
||
} else {
|
||
selfRank.hidden = true;
|
||
}
|
||
}
|
||
|
||
function rankLabel(tier) {
|
||
if (tier.startRank === tier.endRank) {
|
||
return t('giftChallenge.topSingle', 'Top {rank}', {
|
||
rank: tier.startRank,
|
||
}).replace('{rank}', tier.startRank);
|
||
}
|
||
return t('giftChallenge.topRange', 'Top {start}-{end}', {
|
||
start: tier.startRank,
|
||
end: tier.endRank,
|
||
})
|
||
.replace('{start}', tier.startRank)
|
||
.replace('{end}', tier.endRank);
|
||
}
|
||
|
||
function renderRewards() {
|
||
var list = byId('rewardTierList');
|
||
var empty = byId('rewardEmpty');
|
||
if (!list || !state.detail) return;
|
||
list.replaceChildren();
|
||
var tiers = state.detail.rankRewards.OVERALL.length
|
||
? state.detail.rankRewards.OVERALL
|
||
: state.detail.rankRewards.DAILY;
|
||
empty.hidden = tiers.length > 0;
|
||
tiers.forEach(function (tier) {
|
||
var card = createElement('article', 'reward-tier');
|
||
card.appendChild(createElement('h3', '', rankLabel(tier)));
|
||
var strip = createElement('div', 'reward-strip');
|
||
tier.rewards.slice(0, 3).forEach(function (reward) {
|
||
strip.appendChild(rewardSlot(reward));
|
||
});
|
||
card.appendChild(strip);
|
||
list.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function setRankingState(mode) {
|
||
byId('rankingLoading').hidden = mode !== 'loading';
|
||
byId('rankingError').hidden = mode !== 'error';
|
||
byId('rankingContent').hidden = mode !== 'ready';
|
||
if (mode !== 'ready') byId('selfRank').hidden = true;
|
||
}
|
||
|
||
function loadRanking(force) {
|
||
var period = state.currentPeriod;
|
||
if (state.rankingLoading[period]) return state.rankingLoading[period];
|
||
if (!force && state.rankings[period]) {
|
||
renderRanking(state.rankings[period]);
|
||
setRankingState('ready');
|
||
return Promise.resolve(state.rankings[period]);
|
||
}
|
||
setRankingState('loading');
|
||
var request = Promise.resolve()
|
||
.then(function () {
|
||
return api().ranking(
|
||
state.activityId,
|
||
period,
|
||
period === 'DAILY' ? state.detail.statDate : '',
|
||
RANKING_LIMIT
|
||
);
|
||
})
|
||
.then(function (payload) {
|
||
state.rankings[period] = payload || {};
|
||
if (state.currentPeriod === period) {
|
||
renderRanking(payload || {});
|
||
setRankingState('ready');
|
||
}
|
||
return payload;
|
||
})
|
||
.catch(function (error) {
|
||
if (state.currentPeriod === period) setRankingState('error');
|
||
throw error;
|
||
})
|
||
.finally(function () {
|
||
delete state.rankingLoading[period];
|
||
});
|
||
state.rankingLoading[period] = request;
|
||
return request;
|
||
}
|
||
|
||
function updatePrimaryTabs() {
|
||
all('[data-view]', byId('primaryTabs')).forEach(function (button) {
|
||
var active = button.dataset.view === state.currentView;
|
||
button.classList.toggle('is-active', active);
|
||
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
||
});
|
||
all('[data-view-panel]').forEach(function (panel) {
|
||
var active = panel.dataset.viewPanel === state.currentView;
|
||
panel.classList.toggle('is-active', active);
|
||
panel.hidden = !active;
|
||
});
|
||
}
|
||
|
||
function switchView(view) {
|
||
if (['tasks', 'ranking', 'rewards'].indexOf(view) < 0) return;
|
||
state.currentView = view;
|
||
updatePrimaryTabs();
|
||
if (view === 'ranking') {
|
||
loadRanking(false).catch(function () {});
|
||
}
|
||
if (view === 'rewards') renderRewards();
|
||
}
|
||
|
||
function switchPeriod(period) {
|
||
period = period === 'OVERALL' ? 'OVERALL' : 'DAILY';
|
||
state.currentPeriod = period;
|
||
all('[data-period]', byId('rankPeriodTabs')).forEach(function (button) {
|
||
var active = button.dataset.period === period;
|
||
button.classList.toggle('is-active', active);
|
||
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
||
});
|
||
loadRanking(false).catch(function () {});
|
||
}
|
||
|
||
function updateCountdown() {
|
||
if (!state.detail) return;
|
||
var now = Date.now() + state.serverOffsetMs;
|
||
var target =
|
||
state.detail.startTime > now
|
||
? state.detail.startTime
|
||
: state.detail.endTime;
|
||
var remaining = Math.max(0, target - now);
|
||
var seconds = Math.floor(remaining / 1000);
|
||
var values = {
|
||
days: Math.floor(seconds / 86400),
|
||
hours: Math.floor((seconds % 86400) / 3600),
|
||
minutes: Math.floor((seconds % 3600) / 60),
|
||
seconds: seconds % 60,
|
||
};
|
||
Object.keys(values).forEach(function (key) {
|
||
var element = document.querySelector(
|
||
'[data-countdown="' + key + '"]'
|
||
);
|
||
if (!element) return;
|
||
var value = values[key];
|
||
// 天数单位由同格的 data-i18n small 节点负责,strong 只写数值,避免
|
||
// 英文 D 重复且污染阿语、土语文案。
|
||
element.textContent =
|
||
key === 'days' ? String(value) : String(value).padStart(2, '0');
|
||
});
|
||
}
|
||
|
||
function startCountdown() {
|
||
window.clearInterval(state.countdownTimer);
|
||
updateCountdown();
|
||
state.countdownTimer = window.setInterval(updateCountdown, 1000);
|
||
}
|
||
|
||
function renderResultRewards(rewards) {
|
||
var target = byId('resultRewards');
|
||
target.replaceChildren();
|
||
asArray(rewards)
|
||
.slice(0, 3)
|
||
.forEach(function (reward) {
|
||
target.appendChild(rewardSlot(reward));
|
||
});
|
||
}
|
||
|
||
function openModal(id) {
|
||
var modal = byId(id);
|
||
if (!modal) return;
|
||
modal.hidden = false;
|
||
document.body.style.overflow = 'hidden';
|
||
var focusTarget = modal.querySelector('button');
|
||
if (focusTarget) focusTarget.focus();
|
||
}
|
||
|
||
function closeModal(id) {
|
||
var modal = byId(id);
|
||
if (!modal) return;
|
||
modal.hidden = true;
|
||
if (!document.querySelector('.modal:not([hidden])')) {
|
||
document.body.style.overflow = '';
|
||
}
|
||
}
|
||
|
||
function refreshDetail() {
|
||
return Promise.resolve(api().current()).then(function (payload) {
|
||
var refreshed = normalizeDetail(payload);
|
||
// 排行请求与领奖幂等键都以活动 ID 为边界。刷新若意外返回另一活动,
|
||
// 直接替换完整状态并清空缓存,避免把旧活动的名次显示在新周期。
|
||
if (state.activityId && refreshed.activityId !== state.activityId) {
|
||
state.rankings = {};
|
||
}
|
||
state.detail = refreshed;
|
||
state.activityId = refreshed.activityId;
|
||
state.serverOffsetMs = refreshed.serverTime - Date.now();
|
||
renderTasks();
|
||
renderRewards();
|
||
startCountdown();
|
||
return refreshed;
|
||
});
|
||
}
|
||
|
||
function applyUserState(result) {
|
||
if (!state.detail || !result || typeof result !== 'object') return [];
|
||
var tasks = firstDefined(result.tasks, result.taskList);
|
||
if (Array.isArray(tasks)) {
|
||
state.detail.tasks = normalizeTasks(tasks);
|
||
}
|
||
if (result.statDate) state.detail.statDate = text(result.statDate);
|
||
renderTasks();
|
||
return state.detail.tasks;
|
||
}
|
||
|
||
function claimTask(taskCode) {
|
||
if (!state.detail || state.claiming[taskCode]) return;
|
||
var task = state.detail.tasks.find(function (item) {
|
||
return item.code === taskCode;
|
||
});
|
||
if (!task || !task.claimable || task.claimed) return;
|
||
var requestId =
|
||
state.claimRequestIds[taskCode] ||
|
||
makeRequestId('claim:' + taskCode);
|
||
// 同一次点击遇到超时后必须复用 requestId;只有服务端确认成功才删除,
|
||
// 从而把“用户意图重试”和“再次领取”区分开。
|
||
state.claimRequestIds[taskCode] = requestId;
|
||
state.claiming[taskCode] = true;
|
||
renderTasks();
|
||
Promise.resolve(api().claimTask(state.activityId, taskCode, requestId))
|
||
.then(function (result) {
|
||
delete state.claimRequestIds[taskCode];
|
||
// claim 返回完整 UserState;直接应用其中的用户任务快照,不能再用
|
||
// /detail 的静态任务配置覆盖刚刚更新的进度和发奖状态。
|
||
var updatedTasks = applyUserState(result);
|
||
var updatedTask = updatedTasks.find(function (item) {
|
||
return item.code === taskCode;
|
||
});
|
||
if (updatedTask && updatedTask.claimed) {
|
||
renderResultRewards(updatedTask.rewards || task.rewards);
|
||
openModal('resultModal');
|
||
} else {
|
||
showToast(
|
||
t(
|
||
'giftChallenge.rewardPending',
|
||
'Reward delivery is being verified.'
|
||
)
|
||
);
|
||
}
|
||
})
|
||
.catch(function () {
|
||
showToast(
|
||
t(
|
||
'giftChallenge.claimFailed',
|
||
'Unable to claim the reward. Please try again.'
|
||
)
|
||
);
|
||
})
|
||
.finally(function () {
|
||
delete state.claiming[taskCode];
|
||
renderTasks();
|
||
});
|
||
}
|
||
|
||
function goToTask() {
|
||
showToast(
|
||
t('giftChallenge.completeInApp', 'Complete this task in the app.')
|
||
);
|
||
window.setTimeout(function () {
|
||
if (window.AslanCommon && window.AslanCommon.back) {
|
||
window.AslanCommon.back();
|
||
}
|
||
}, 350);
|
||
}
|
||
|
||
function recordDailyEntry() {
|
||
if (!state.activityId) return Promise.resolve();
|
||
return Promise.resolve(api().enter(state.activityId))
|
||
.then(function (result) {
|
||
var tasks = firstDefined(
|
||
result && result.tasks,
|
||
result && result.taskList
|
||
);
|
||
if (Array.isArray(tasks)) {
|
||
applyUserState(result);
|
||
} else {
|
||
// enter 可能只返回成功标记;后台仍是任务状态唯一真相,轻量
|
||
// 再读 detail 以免页面继续显示进入任务未完成。
|
||
return refreshDetail();
|
||
}
|
||
})
|
||
.catch(function () {
|
||
// 进入上报失败不应让排行榜和奖励整页不可用;下次打开由服务端
|
||
// 活动日任务唯一键继续幂等补记。
|
||
});
|
||
}
|
||
|
||
function setPageState(mode) {
|
||
byId('pageLoading').hidden = mode !== 'loading';
|
||
byId('pageError').hidden = mode !== 'error';
|
||
byId('tasksContent').hidden = mode !== 'ready';
|
||
}
|
||
|
||
function loadPage() {
|
||
setPageState('loading');
|
||
return refreshDetail()
|
||
.then(function () {
|
||
if (!state.activityId) throw new Error('activity_not_found');
|
||
setPageState('ready');
|
||
return recordDailyEntry();
|
||
})
|
||
.catch(function () {
|
||
setPageState('error');
|
||
});
|
||
}
|
||
|
||
function bindEvents() {
|
||
byId('primaryTabs').addEventListener('click', function (event) {
|
||
var button = event.target.closest('[data-view]');
|
||
if (button) switchView(button.dataset.view);
|
||
});
|
||
byId('rankPeriodTabs').addEventListener('click', function (event) {
|
||
var button = event.target.closest('[data-period]');
|
||
if (button) switchPeriod(button.dataset.period);
|
||
});
|
||
byId('taskList').addEventListener('click', function (event) {
|
||
var button = event.target.closest('[data-action]');
|
||
if (!button) return;
|
||
if (button.dataset.action === 'claim') {
|
||
claimTask(button.dataset.taskCode);
|
||
} else if (button.dataset.action === 'go') {
|
||
goToTask();
|
||
}
|
||
});
|
||
byId('ruleButton').addEventListener('click', function () {
|
||
openModal('ruleModal');
|
||
});
|
||
byId('rewardButton').addEventListener('click', function () {
|
||
switchView('rewards');
|
||
});
|
||
byId('rewardBack').addEventListener('click', function () {
|
||
switchView('ranking');
|
||
});
|
||
byId('retryButton').addEventListener('click', loadPage);
|
||
byId('rankingRetry').addEventListener('click', function () {
|
||
loadRanking(true).catch(function () {});
|
||
});
|
||
document.addEventListener('click', function (event) {
|
||
var closer = event.target.closest('[data-close-modal]');
|
||
if (closer) closeModal(closer.dataset.closeModal);
|
||
});
|
||
document.addEventListener('keydown', function (event) {
|
||
if (event.key !== 'Escape') return;
|
||
all('.modal:not([hidden])').forEach(function (modal) {
|
||
closeModal(modal.id);
|
||
});
|
||
});
|
||
window.addEventListener('hyapp:i18n-ready', function () {
|
||
renderTasks();
|
||
renderRewards();
|
||
Object.keys(state.rankings).forEach(function (period) {
|
||
if (period === state.currentPeriod) {
|
||
renderRanking(state.rankings[period]);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
bindEvents();
|
||
updatePrimaryTabs();
|
||
loadPage();
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|