1268 lines
35 KiB
JavaScript
1268 lines
35 KiB
JavaScript
const query = new URLSearchParams(window.location.search);
|
|
|
|
const LOCAL_INVITE_API_BASE = "http://127.0.0.1:2900/";
|
|
const LOCAL_PROXY_API_BASE = "/api/";
|
|
const LOCAL_FILE_PROXY_API_BASE = "http://127.0.0.1:8800/api/";
|
|
const ONLINE_INVITE_API_BASE = "https://jvapi.haiyihy.com/";
|
|
const ONLINE_INVITE_API_PREFIX = "/go";
|
|
const DEFAULT_AVATAR = "./assets/avatar-sample.png";
|
|
const INVITEE_DISPLAY_GIFT_REWARD = 1000;
|
|
const DEFAULT_RESET_PERIOD_MS = 30 * 86400 * 1000;
|
|
const DEFAULT_REQUEST_HEADERS = {
|
|
"Req-Sys-Origin": "origin=LIKEI;originChild=LIKEI",
|
|
"Req-Client": "Android",
|
|
"Req-App-Intel": "version=1.0.0;build=1;channel=Web;model=H5;sysVersion=Web",
|
|
"Req-Lang": "en",
|
|
"Req-Version": "V2",
|
|
"Req-Zone": "Asia/Shanghai",
|
|
};
|
|
const DEFAULT_TASK_NOTES = {
|
|
transport: "(Only the highest-level bonuses may be awarded)",
|
|
quota: "(Only invited users who reach the required recharge amount count as valid invites)",
|
|
};
|
|
const INVITE_SHARE_COPY_PREFIX =
|
|
"Download the app, invite your friends to join, and earn 100,000 coins plus a special welcome gift!";
|
|
|
|
const DEFAULTS = {
|
|
view: "invite",
|
|
inviteLink: "",
|
|
inviteCode: "",
|
|
invitees: "0",
|
|
eligibleVoters: "0",
|
|
rechargeTotal: "0",
|
|
myRewards: "0",
|
|
friendName: "Name",
|
|
downloadUrl: "",
|
|
inviterBindReward: 0,
|
|
rechargeReward: 0,
|
|
giftReward: INVITEE_DISPLAY_GIFT_REWARD,
|
|
};
|
|
|
|
const FALLBACK_TASKS = {
|
|
transport: [],
|
|
quota: [],
|
|
};
|
|
|
|
const state = {
|
|
view: resolveInitialView(),
|
|
apiBase: resolveAPIBase(),
|
|
apiPrefix: resolveAPIPrefix(),
|
|
authorization: resolveAuthorization(),
|
|
requestHeaders: resolveRequestHeaders(),
|
|
landingCode: resolveLandingCode(),
|
|
inviteLink: query.get("inviteLink") || DEFAULTS.inviteLink,
|
|
inviteCode: query.get("inviteCode") || DEFAULTS.inviteCode,
|
|
invitees: query.get("invitees") || DEFAULTS.invitees,
|
|
eligibleVoters: query.get("eligible") || DEFAULTS.eligibleVoters,
|
|
rechargeTotal: query.get("recharge") || DEFAULTS.rechargeTotal,
|
|
myRewards: query.get("rewards") || DEFAULTS.myRewards,
|
|
friendName: query.get("friendName") || DEFAULTS.friendName,
|
|
inviterAvatar: DEFAULT_AVATAR,
|
|
downloadUrl: query.get("downloadUrl") || DEFAULTS.downloadUrl,
|
|
inviterBindReward: query.get("inviterReward") || DEFAULTS.inviterBindReward,
|
|
rechargeReward: query.get("rechargeReward") || DEFAULTS.rechargeReward,
|
|
giftReward: INVITEE_DISPLAY_GIFT_REWARD,
|
|
taskSet: normalizeTaskSet(query.get("taskSet") || "quota"),
|
|
tasks: {
|
|
transport: cloneTasks(FALLBACK_TASKS.transport),
|
|
quota: cloneTasks(FALLBACK_TASKS.quota),
|
|
},
|
|
rankItems: [],
|
|
serverOffsetMs: 0,
|
|
countdownEndAtMs: Date.now() + DEFAULT_RESET_PERIOD_MS,
|
|
countdownTimer: null,
|
|
pendingClaimRuleId: "",
|
|
};
|
|
|
|
const els = {
|
|
views: [...document.querySelectorAll("[data-view]")],
|
|
inviteLinkText: document.getElementById("inviteLinkText"),
|
|
inviteCodeText: document.getElementById("inviteCodeText"),
|
|
statInvitees: document.getElementById("statInvitees"),
|
|
statEligible: document.getElementById("statEligible"),
|
|
statRecharge: document.getElementById("statRecharge"),
|
|
statRewards: document.getElementById("statRewards"),
|
|
taskList: document.getElementById("taskList"),
|
|
taskNote: document.getElementById("taskNote"),
|
|
taskTabs: [...document.querySelectorAll("[data-task-set]")],
|
|
navButtons: [...document.querySelectorAll("[data-nav-view]")],
|
|
copyButtons: [...document.querySelectorAll("[data-copy-target]")],
|
|
rankList: document.getElementById("rankList"),
|
|
rankLists: [...document.querySelectorAll("[data-rank-list]")],
|
|
giftTitle: document.getElementById("giftTitle"),
|
|
giftAvatarImage: document.getElementById("giftAvatarImage"),
|
|
downloadButton: document.getElementById("downloadButton"),
|
|
inviteRewardAmounts: [
|
|
...document.querySelectorAll('.reward-panel .reward-amount'),
|
|
],
|
|
giftRewardAmount: document.querySelector('.gift-panel .reward-amount'),
|
|
toast: document.getElementById("pageToast"),
|
|
countdown: {
|
|
days: document.querySelector('[data-countdown="days"]'),
|
|
hours: document.querySelector('[data-countdown="hours"]'),
|
|
minutes: document.querySelector('[data-countdown="minutes"]'),
|
|
seconds: document.querySelector('[data-countdown="seconds"]'),
|
|
},
|
|
};
|
|
|
|
function normalizeView(value) {
|
|
if (value === "rank" || value === "gift") {
|
|
return value;
|
|
}
|
|
|
|
return "invite";
|
|
}
|
|
|
|
function normalizeTaskSet(value) {
|
|
return value === "transport" ? "transport" : "quota";
|
|
}
|
|
|
|
function trimValue(value) {
|
|
return value == null ? "" : String(value).trim();
|
|
}
|
|
|
|
function resolveLandingCode() {
|
|
return trimValue(
|
|
query.get("code") ||
|
|
query.get("landingCode") ||
|
|
query.get("invitationCode") ||
|
|
query.get("inviteCode"),
|
|
);
|
|
}
|
|
|
|
function hasAuthorizationInput() {
|
|
return Boolean(
|
|
trimValue(query.get("authorization")) ||
|
|
trimValue(query.get("Authorization")) ||
|
|
trimValue(query.get("token")) ||
|
|
trimValue(query.get("token?")) ||
|
|
trimValue(query.get("accessToken")) ||
|
|
trimValue(query.get("auth")) ||
|
|
(typeof window.__APP_INVITE_AUTHORIZATION__ === "string" &&
|
|
trimValue(window.__APP_INVITE_AUTHORIZATION__)) ||
|
|
(typeof window.__APP_INVITE_TOKEN__ === "string" && trimValue(window.__APP_INVITE_TOKEN__)),
|
|
);
|
|
}
|
|
|
|
function resolveInitialView() {
|
|
const explicitView = trimValue(query.get("view"));
|
|
if (explicitView) {
|
|
return normalizeView(explicitView);
|
|
}
|
|
|
|
if (resolveLandingCode() && !hasAuthorizationInput()) {
|
|
return "gift";
|
|
}
|
|
|
|
return DEFAULTS.view;
|
|
}
|
|
|
|
function normalizeBaseURL(base) {
|
|
const value = trimValue(base);
|
|
if (!value) {
|
|
return resolveDefaultAPIBase();
|
|
}
|
|
|
|
return value.endsWith("/") ? value : `${value}/`;
|
|
}
|
|
|
|
function isLocalHost(hostname = window.location.hostname) {
|
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
}
|
|
|
|
function isOnlineAPIBase(base) {
|
|
try {
|
|
return new URL(base, window.location.href).origin === new URL(ONLINE_INVITE_API_BASE).origin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isAbsoluteURL(value) {
|
|
return /^https?:\/\//i.test(trimValue(value));
|
|
}
|
|
|
|
function resolveDefaultAPIBase() {
|
|
if (window.location.protocol === "file:") {
|
|
return LOCAL_FILE_PROXY_API_BASE;
|
|
}
|
|
|
|
if (window.location.protocol === "http:" && isLocalHost()) {
|
|
return LOCAL_PROXY_API_BASE;
|
|
}
|
|
|
|
if (window.location.protocol === "https:" && !isLocalHost()) {
|
|
return ONLINE_INVITE_API_BASE;
|
|
}
|
|
|
|
return LOCAL_INVITE_API_BASE;
|
|
}
|
|
|
|
function resolveAPIBase() {
|
|
const queryBase = trimValue(query.get("apiBase"));
|
|
if (queryBase) {
|
|
return normalizeBaseURL(queryBase);
|
|
}
|
|
|
|
if (typeof window.__APP_INVITE_API_BASE__ === "string") {
|
|
return normalizeBaseURL(window.__APP_INVITE_API_BASE__);
|
|
}
|
|
|
|
return normalizeBaseURL(resolveDefaultAPIBase());
|
|
}
|
|
|
|
function resolveAPIPrefix() {
|
|
const queryPrefix = trimValue(query.get("apiPrefix"));
|
|
if (queryPrefix) {
|
|
return queryPrefix;
|
|
}
|
|
|
|
if (typeof window.__APP_INVITE_API_PREFIX__ === "string") {
|
|
return trimValue(window.__APP_INVITE_API_PREFIX__);
|
|
}
|
|
|
|
return isOnlineAPIBase(resolveAPIBase()) ? ONLINE_INVITE_API_PREFIX : "";
|
|
}
|
|
|
|
function resolveAuthorization() {
|
|
const candidates = [
|
|
query.get("authorization"),
|
|
query.get("Authorization"),
|
|
query.get("token"),
|
|
query.get("token?"),
|
|
query.get("accessToken"),
|
|
query.get("auth"),
|
|
typeof window.__APP_INVITE_AUTHORIZATION__ === "string"
|
|
? window.__APP_INVITE_AUTHORIZATION__
|
|
: "",
|
|
typeof window.__APP_INVITE_TOKEN__ === "string" ? window.__APP_INVITE_TOKEN__ : "",
|
|
];
|
|
|
|
const value = candidates.find((item) => trimValue(item));
|
|
if (!value) {
|
|
return "";
|
|
}
|
|
|
|
const normalized = trimValue(value);
|
|
if (/^bearer\s+/i.test(normalized)) {
|
|
return normalized;
|
|
}
|
|
|
|
return `Bearer ${normalized}`;
|
|
}
|
|
|
|
function resolveRequestHeaders() {
|
|
const headers = { ...DEFAULT_REQUEST_HEADERS };
|
|
const sysOrigin = trimValue(query.get("sysOrigin") || query.get("origin"));
|
|
const sysOriginChild = trimValue(
|
|
query.get("sysOriginChild") || query.get("originChild") || sysOrigin,
|
|
);
|
|
const reqClient = trimValue(query.get("reqClient") || query.get("client"));
|
|
const reqLang = trimValue(query.get("reqLang") || query.get("lang"));
|
|
const reqZone = trimValue(query.get("reqZone") || query.get("zone"));
|
|
const reqVersion = trimValue(query.get("reqVersion") || query.get("version"));
|
|
const reqImei = trimValue(query.get("reqImei") || query.get("imei"));
|
|
const appVersion = trimValue(query.get("appVersion")) || "1.0.0";
|
|
const buildVersion = trimValue(query.get("buildVersion") || query.get("build")) || "1";
|
|
const appChannel = trimValue(query.get("appChannel") || query.get("channel")) || "Web";
|
|
|
|
if (sysOrigin) {
|
|
headers["Req-Sys-Origin"] = `origin=${sysOrigin};originChild=${sysOriginChild || sysOrigin}`;
|
|
}
|
|
if (reqClient) {
|
|
headers["Req-Client"] = reqClient;
|
|
}
|
|
if (reqLang) {
|
|
headers["Req-Lang"] = reqLang;
|
|
}
|
|
if (reqZone) {
|
|
headers["Req-Zone"] = reqZone;
|
|
}
|
|
if (reqVersion) {
|
|
headers["Req-Version"] = reqVersion;
|
|
}
|
|
if (reqImei) {
|
|
headers["Req-Imei"] = reqImei;
|
|
}
|
|
|
|
headers["Req-App-Intel"] = buildAppIntelHeader({
|
|
version: appVersion,
|
|
build: buildVersion,
|
|
channel: appChannel,
|
|
model: trimValue(query.get("model")) || "H5",
|
|
sysVersion: trimValue(query.get("sysVersion")) || "Web",
|
|
imei: reqImei,
|
|
});
|
|
|
|
return headers;
|
|
}
|
|
|
|
function buildAppIntelHeader(parts) {
|
|
const items = [];
|
|
const version = trimValue(parts?.version) || "1.0.0";
|
|
const build = trimValue(parts?.build) || "1";
|
|
const channel = trimValue(parts?.channel) || "Web";
|
|
const model = trimValue(parts?.model);
|
|
const sysVersion = trimValue(parts?.sysVersion);
|
|
const imei = trimValue(parts?.imei);
|
|
|
|
items.push(`version=${version}`);
|
|
items.push(`build=${build}`);
|
|
items.push(`channel=${channel}`);
|
|
if (model) {
|
|
items.push(`model=${model}`);
|
|
}
|
|
if (sysVersion) {
|
|
items.push(`sysVersion=${sysVersion}`);
|
|
}
|
|
if (imei) {
|
|
items.push(`Req-Imei=${imei}`);
|
|
}
|
|
|
|
return items.join(";");
|
|
}
|
|
|
|
function splitHeaderPairs(value) {
|
|
const result = {};
|
|
trimValue(value)
|
|
.split(";")
|
|
.forEach((item) => {
|
|
const index = item.indexOf("=");
|
|
if (index > -1) {
|
|
result[item.slice(0, index)] = item.slice(index + 1);
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function parseAccessOrigin(raw) {
|
|
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
if (!data || typeof data !== "object") {
|
|
return {};
|
|
}
|
|
|
|
const appIntel = splitHeaderPairs(data["Req-App-Intel"] || data["req-app-intel"]);
|
|
const sysOrigin = splitHeaderPairs(data["Req-Sys-Origin"] || data["req-sys-origin"]);
|
|
|
|
return {
|
|
authorization: data.Authorization || data.authorization,
|
|
reqLang: data["Req-Lang"] || data["req-lang"],
|
|
reqClient: data["Req-Client"] || data["req-client"],
|
|
reqImei: data["Req-Imei"] || data["req-imei"] || appIntel["Req-Imei"],
|
|
appVersion: appIntel.version,
|
|
buildVersion: appIntel.build,
|
|
appChannel: appIntel.channel,
|
|
model: appIntel.model,
|
|
sysVersion: appIntel.sysVersion,
|
|
sysOrigin: sysOrigin.origin,
|
|
sysOriginChild: sysOrigin.originChild || sysOrigin.child,
|
|
};
|
|
}
|
|
|
|
function setHeadersFromApp(info) {
|
|
if (!info || typeof info !== "object") {
|
|
return;
|
|
}
|
|
|
|
const next = {};
|
|
const authorization = normalizeAuthorizationValue(info.authorization);
|
|
if (authorization) {
|
|
state.authorization = authorization;
|
|
}
|
|
if (info.reqLang) {
|
|
next["Req-Lang"] = info.reqLang;
|
|
}
|
|
if (info.reqClient) {
|
|
next["Req-Client"] = info.reqClient;
|
|
}
|
|
if (info.reqImei) {
|
|
next["Req-Imei"] = info.reqImei;
|
|
}
|
|
if (info.sysOrigin) {
|
|
next["Req-Sys-Origin"] =
|
|
`origin=${info.sysOrigin};originChild=${info.sysOriginChild || info.sysOrigin}`;
|
|
}
|
|
if (info.appVersion || info.buildVersion || info.appChannel || info.model || info.sysVersion) {
|
|
next["Req-App-Intel"] = buildAppIntelHeader({
|
|
version: info.appVersion,
|
|
build: info.buildVersion,
|
|
channel: info.appChannel,
|
|
model: info.model,
|
|
sysVersion: info.sysVersion,
|
|
imei: info.reqImei,
|
|
});
|
|
}
|
|
|
|
state.requestHeaders = { ...state.requestHeaders, ...next };
|
|
}
|
|
|
|
function normalizeAuthorizationValue(value) {
|
|
const normalized = trimValue(value);
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
if (/^bearer\s+/i.test(normalized)) {
|
|
return normalized;
|
|
}
|
|
return `Bearer ${normalized}`;
|
|
}
|
|
|
|
function connectToApp() {
|
|
return new Promise((resolve) => {
|
|
let finished = false;
|
|
const finish = (value) => {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
finished = true;
|
|
resolve(value);
|
|
};
|
|
|
|
window.renderData = finish;
|
|
window.getIosAccessOriginParam = finish;
|
|
|
|
if (!(window.app || window.webkit || window.FlutterPageControl || window.FlutterApp)) {
|
|
finish(null);
|
|
return;
|
|
}
|
|
|
|
let attempt = 0;
|
|
const poll = () => {
|
|
attempt += 1;
|
|
if (window.app && typeof window.app.getAccessOrigin === "function") {
|
|
try {
|
|
finish(window.app.getAccessOrigin());
|
|
} catch {
|
|
finish(null);
|
|
}
|
|
return;
|
|
}
|
|
if (attempt < 50) {
|
|
window.setTimeout(poll, 100);
|
|
return;
|
|
}
|
|
if (window.FlutterApp && typeof window.FlutterApp.postMessage === "function") {
|
|
window.FlutterApp.postMessage("requestAccessOrigin");
|
|
}
|
|
window.setTimeout(() => finish(null), 300);
|
|
};
|
|
|
|
poll();
|
|
}).then((value) => {
|
|
if (value) {
|
|
try {
|
|
setHeadersFromApp(parseAccessOrigin(value));
|
|
} catch {
|
|
// App bridge data is best-effort; query token and default gateway headers still work.
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value).replace(/[&<>"']/g, (match) => {
|
|
const entityMap = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
};
|
|
return entityMap[match] || match;
|
|
});
|
|
}
|
|
|
|
function cloneTasks(list) {
|
|
return list.map((item) => ({ ...item }));
|
|
}
|
|
|
|
function showToast(text) {
|
|
if (!text) {
|
|
return;
|
|
}
|
|
|
|
els.toast.textContent = text;
|
|
els.toast.classList.add("show");
|
|
|
|
window.clearTimeout(showToast.timer);
|
|
showToast.timer = window.setTimeout(() => {
|
|
els.toast.classList.remove("show");
|
|
}, 1800);
|
|
}
|
|
|
|
function loadImage(image) {
|
|
if (!image || image.getAttribute("src")) {
|
|
return;
|
|
}
|
|
|
|
const src = image.getAttribute("data-src");
|
|
if (src) {
|
|
image.setAttribute("src", src);
|
|
}
|
|
}
|
|
|
|
function setImageSource(image, src, fallbackSrc = "") {
|
|
if (!(image instanceof HTMLImageElement)) {
|
|
return;
|
|
}
|
|
|
|
const nextSrc = trimValue(src) || fallbackSrc;
|
|
if (!nextSrc) {
|
|
return;
|
|
}
|
|
|
|
image.setAttribute("src", nextSrc);
|
|
image.setAttribute("data-src", nextSrc);
|
|
}
|
|
|
|
function hydrateViewAssets(view) {
|
|
const targets = {
|
|
invite: [
|
|
document.getElementById("inviteHeroImage"),
|
|
...document.querySelectorAll('.view[data-view="invite"] .lazy-image'),
|
|
],
|
|
rank: [
|
|
...document.querySelectorAll('.view[data-view="rank"] [data-badge-src]'),
|
|
...document.querySelectorAll('.view[data-view="rank"] [data-avatar-src]'),
|
|
],
|
|
gift: [
|
|
document.getElementById("giftBgImage"),
|
|
document.getElementById("giftAvatarImage"),
|
|
document.getElementById("phoneHomeImage"),
|
|
document.getElementById("phoneGiftImage"),
|
|
...document.querySelectorAll('.view[data-view="gift"] .lazy-image'),
|
|
],
|
|
};
|
|
|
|
(targets[view] || []).forEach((item) => {
|
|
if (item instanceof HTMLImageElement) {
|
|
loadImage(item);
|
|
}
|
|
});
|
|
}
|
|
|
|
function setView(view) {
|
|
state.view = normalizeView(view);
|
|
els.views.forEach((section) => {
|
|
section.classList.toggle("active", section.getAttribute("data-view") === state.view);
|
|
});
|
|
|
|
hydrateViewAssets(state.view);
|
|
|
|
const url = new URL(window.location.href);
|
|
if (state.view === DEFAULTS.view) {
|
|
url.searchParams.delete("view");
|
|
} else {
|
|
url.searchParams.set("view", state.view);
|
|
}
|
|
window.history.replaceState({}, "", url.toString());
|
|
}
|
|
|
|
function formatMetric(value) {
|
|
const raw = trimValue(value);
|
|
if (!raw) {
|
|
return "--";
|
|
}
|
|
|
|
if (/^-?\d+(\.\d+)?$/.test(raw)) {
|
|
return new Intl.NumberFormat("en-US").format(Number(raw));
|
|
}
|
|
|
|
return raw;
|
|
}
|
|
|
|
function formatSignedReward(value) {
|
|
const raw = trimValue(value);
|
|
if (!raw) {
|
|
return "+0";
|
|
}
|
|
|
|
if (raw.startsWith("+")) {
|
|
return raw;
|
|
}
|
|
|
|
return `+${formatMetric(raw)}`;
|
|
}
|
|
|
|
function buildInviteShareURL(inviteLink, inviteCode) {
|
|
const rawURL = trimValue(inviteLink);
|
|
const code = trimValue(inviteCode);
|
|
if (!rawURL || !code) {
|
|
return rawURL;
|
|
}
|
|
|
|
try {
|
|
const url = new URL(rawURL, window.location.href);
|
|
if (url.hostname.includes("play.google.com")) {
|
|
const referrer = new URLSearchParams(url.searchParams.get("referrer") || "");
|
|
setPlayInstallReferrer(referrer, code);
|
|
removeTopLevelInviteCodeParams(url.searchParams);
|
|
url.searchParams.set("referrer", referrer.toString());
|
|
return url.toString();
|
|
}
|
|
|
|
setInviteCodeParams(url.searchParams, code);
|
|
return url.toString();
|
|
} catch {
|
|
const separator = rawURL.includes("?") ? "&" : "?";
|
|
const params = new URLSearchParams();
|
|
setInviteCodeParams(params, code);
|
|
return `${rawURL}${separator}${params.toString()}`;
|
|
}
|
|
}
|
|
|
|
function currentInviteShareURL() {
|
|
return buildInviteShareURL(state.inviteLink, state.inviteCode);
|
|
}
|
|
|
|
function currentInviteShareCopyText() {
|
|
const inviteURL = trimValue(currentInviteShareURL());
|
|
return inviteURL ? `${INVITE_SHARE_COPY_PREFIX} ${inviteURL}` : "";
|
|
}
|
|
|
|
function renderStats() {
|
|
els.inviteLinkText.textContent = trimValue(currentInviteShareURL()) || "--";
|
|
els.inviteCodeText.textContent = trimValue(state.inviteCode) || "--";
|
|
els.statInvitees.textContent = formatMetric(state.invitees);
|
|
els.statEligible.textContent = formatMetric(state.eligibleVoters);
|
|
els.statRecharge.textContent = formatMetric(state.rechargeTotal);
|
|
els.statRewards.textContent = formatMetric(state.myRewards);
|
|
els.giftTitle.innerHTML = `Your friend [${escapeHtml(state.friendName)}]<br />has invited you to join`;
|
|
setImageSource(els.giftAvatarImage, state.inviterAvatar, DEFAULT_AVATAR);
|
|
|
|
if (els.inviteRewardAmounts[0]) {
|
|
els.inviteRewardAmounts[0].textContent = formatSignedReward(state.inviterBindReward);
|
|
}
|
|
if (els.inviteRewardAmounts[1]) {
|
|
els.inviteRewardAmounts[1].textContent = formatSignedReward(state.rechargeReward);
|
|
}
|
|
if (els.giftRewardAmount) {
|
|
els.giftRewardAmount.textContent = formatSignedReward(state.giftReward);
|
|
}
|
|
}
|
|
|
|
function renderTasks() {
|
|
const current = state.tasks[state.taskSet] || cloneTasks(FALLBACK_TASKS[state.taskSet]);
|
|
els.taskTabs.forEach((tab) => {
|
|
const active = tab.getAttribute("data-task-set") === state.taskSet;
|
|
tab.classList.toggle("active", active);
|
|
tab.classList.toggle("passive", !active);
|
|
});
|
|
|
|
if (current.length === 0) {
|
|
els.taskList.innerHTML = `
|
|
<article class="task-card">
|
|
<div>
|
|
<p class="task-title">No tasks yet</p>
|
|
<p class="task-desc">Task data will appear after the activity config is loaded.</p>
|
|
</div>
|
|
<button type="button" class="claim-button is-pending disabled" disabled>
|
|
Incomplete
|
|
</button>
|
|
</article>
|
|
`;
|
|
els.taskNote.textContent = DEFAULT_TASK_NOTES[state.taskSet] || "";
|
|
return;
|
|
}
|
|
|
|
els.taskList.innerHTML = current
|
|
.map((item) => {
|
|
const ruleId = trimValue(item.ruleId);
|
|
const isLoading = Boolean(ruleId) && state.pendingClaimRuleId === ruleId;
|
|
const disabled = item.disabled || isLoading || !ruleId;
|
|
const buttonLabel = isLoading ? "Loading..." : item.buttonLabel;
|
|
let buttonStateClass = " is-pending";
|
|
|
|
if (isLoading) {
|
|
buttonStateClass = " is-loading";
|
|
} else if (item.claimed) {
|
|
buttonStateClass = " is-claimed";
|
|
} else if (item.achieved && !item.claimed) {
|
|
buttonStateClass = " is-claimable";
|
|
}
|
|
|
|
return `
|
|
<article class="task-card">
|
|
<div>
|
|
<p class="task-title">${escapeHtml(item.title)}</p>
|
|
<p class="task-desc">${escapeHtml(item.desc)}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="claim-button${buttonStateClass}${disabled ? " disabled" : ""}"
|
|
data-rule-id="${escapeHtml(ruleId)}"
|
|
${disabled ? "disabled" : ""}
|
|
>
|
|
${escapeHtml(buttonLabel)}
|
|
</button>
|
|
</article>
|
|
`;
|
|
})
|
|
.join("");
|
|
els.taskNote.textContent = DEFAULT_TASK_NOTES[state.taskSet] || "";
|
|
}
|
|
|
|
function rankBadgeMarkup(item) {
|
|
const badgeMap = {
|
|
1: "./assets/rank-top1.png",
|
|
2: "./assets/rank-top2.png",
|
|
3: "./assets/rank-top3.png",
|
|
};
|
|
const badge = badgeMap[item.rank];
|
|
return badge
|
|
? `<img src="${badge}" alt="Rank ${item.rank}" />`
|
|
: escapeHtml(String(item.rank));
|
|
}
|
|
|
|
function renderRankRows(container) {
|
|
const list = state.rankItems;
|
|
const inline = container?.dataset?.rankVariant === "inline";
|
|
if (!Array.isArray(list) || list.length === 0) {
|
|
return inline
|
|
? `
|
|
<article
|
|
class="rank-row"
|
|
style="background-image:url('./assets/rank-row.svg')"
|
|
>
|
|
<div class="rank-badge">--</div>
|
|
<div class="avatar">
|
|
<img src="${DEFAULT_AVATAR}" alt="" />
|
|
</div>
|
|
<div class="rank-name">No ranking data yet</div>
|
|
<div class="rank-score">0 person</div>
|
|
<div class="rank-reward">0</div>
|
|
</article>
|
|
`
|
|
: `
|
|
<article
|
|
class="rank-row"
|
|
style="background-image:url('./assets/rank-row.svg')"
|
|
>
|
|
<div class="rank-badge">--</div>
|
|
<div class="avatar">
|
|
<img src="${DEFAULT_AVATAR}" alt="" />
|
|
</div>
|
|
<div class="rank-name">No ranking data yet</div>
|
|
<div class="rank-score">0 people</div>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
return list.map((item) => {
|
|
if (inline) {
|
|
return `
|
|
<article
|
|
class="rank-row"
|
|
style="background-image:url('./assets/rank-row.svg')"
|
|
>
|
|
<div class="rank-badge">${rankBadgeMarkup(item)}</div>
|
|
<div class="avatar">
|
|
<img src="${escapeHtml(item.avatar || DEFAULT_AVATAR)}" alt="${escapeHtml(item.name)}" />
|
|
</div>
|
|
<div class="rank-name">${escapeHtml(item.name)}</div>
|
|
<div class="rank-score">${escapeHtml(item.inviteCountText)}</div>
|
|
<div class="rank-reward">${escapeHtml(item.rewardText)}</div>
|
|
</article>
|
|
`;
|
|
}
|
|
return `
|
|
<article
|
|
class="rank-row"
|
|
style="background-image:url('./assets/rank-row.svg')"
|
|
>
|
|
<div class="rank-badge">${rankBadgeMarkup(item)}</div>
|
|
<div class="avatar">
|
|
<img src="${escapeHtml(item.avatar || DEFAULT_AVATAR)}" alt="${escapeHtml(item.name)}" />
|
|
</div>
|
|
<div class="rank-name">${escapeHtml(item.name)}</div>
|
|
<div class="rank-score">${escapeHtml(item.score)}</div>
|
|
</article>
|
|
`;
|
|
}).join("");
|
|
}
|
|
|
|
function renderRankList() {
|
|
const containers = els.rankLists.length > 0
|
|
? els.rankLists
|
|
: [els.rankList].filter(Boolean);
|
|
containers.forEach((container) => {
|
|
container.innerHTML = renderRankRows(container);
|
|
});
|
|
}
|
|
|
|
function pad(number) {
|
|
return String(number).padStart(2, "0");
|
|
}
|
|
|
|
function updateCountdown() {
|
|
const now = Date.now() + state.serverOffsetMs;
|
|
const diff = Math.max(0, Number(state.countdownEndAtMs || 0) - now);
|
|
const seconds = Math.floor(diff / 1000);
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const secs = seconds % 60;
|
|
|
|
els.countdown.days.textContent = pad(days);
|
|
els.countdown.hours.textContent = pad(hours);
|
|
els.countdown.minutes.textContent = pad(minutes);
|
|
els.countdown.seconds.textContent = pad(secs);
|
|
}
|
|
|
|
function setupCountdown() {
|
|
updateCountdown();
|
|
state.countdownTimer = window.setInterval(updateCountdown, 1000);
|
|
}
|
|
|
|
async function copyText(text) {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
return;
|
|
}
|
|
|
|
const input = document.createElement("textarea");
|
|
input.value = text;
|
|
input.setAttribute("readonly", "");
|
|
input.style.position = "absolute";
|
|
input.style.left = "-9999px";
|
|
document.body.appendChild(input);
|
|
input.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(input);
|
|
}
|
|
|
|
function extractErrorMessage(payload) {
|
|
const candidates = [
|
|
payload?.errorMsg,
|
|
payload?.message,
|
|
payload?.errorCodeName,
|
|
payload?.code,
|
|
];
|
|
|
|
return candidates.find((item) => trimValue(item)) || "";
|
|
}
|
|
|
|
async function requestJSON(path, options = {}) {
|
|
const { method = "GET", headers = {}, body, searchParams } = options;
|
|
const url = buildAPIURL(path, searchParams);
|
|
const requestHeaders = shouldAttachGatewayHeaders(path) ? state.requestHeaders : {};
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method,
|
|
headers: {
|
|
Accept: "application/json",
|
|
...requestHeaders,
|
|
...headers,
|
|
},
|
|
body,
|
|
});
|
|
|
|
const contentType = response.headers.get("content-type") || "";
|
|
const payload = contentType.includes("application/json") ? await response.json() : null;
|
|
|
|
if (!response.ok || payload?.status === false) {
|
|
const message = extractErrorMessage(payload) || `Request failed (${response.status})`;
|
|
throw new Error(message);
|
|
}
|
|
|
|
return payload?.body || payload?.data || payload || null;
|
|
}
|
|
|
|
function shouldAttachGatewayHeaders(path) {
|
|
const normalizedPath = joinURLPath(path);
|
|
return !normalizedPath.startsWith("/public/");
|
|
}
|
|
|
|
function joinURLPath(...parts) {
|
|
return `/${parts
|
|
.map((part) => trimValue(part).replace(/^\/+|\/+$/g, ""))
|
|
.filter(Boolean)
|
|
.join("/")}`;
|
|
}
|
|
|
|
function buildAPIURL(path, searchParams = {}) {
|
|
const apiPath = joinURLPath(state.apiPrefix, path);
|
|
let url;
|
|
|
|
if (isAbsoluteURL(state.apiBase)) {
|
|
url = new URL(apiPath.replace(/^\/+/, ""), state.apiBase);
|
|
} else {
|
|
url = new URL(joinURLPath(state.apiBase, apiPath), window.location.href);
|
|
}
|
|
|
|
Object.entries(searchParams || {}).forEach(([key, value]) => {
|
|
const raw = trimValue(value);
|
|
if (raw) {
|
|
url.searchParams.set(key, raw);
|
|
}
|
|
});
|
|
|
|
return url;
|
|
}
|
|
|
|
function normalizeTaskList(list, taskSet) {
|
|
if (!Array.isArray(list) || list.length === 0) {
|
|
return cloneTasks(FALLBACK_TASKS[taskSet]);
|
|
}
|
|
|
|
return list.map((item, index) => {
|
|
const threshold = Number(item?.threshold || 0);
|
|
const goldAmount = Number(item?.goldAmount || 0);
|
|
const achieved = Boolean(item?.achieved);
|
|
const claimed = Boolean(item?.claimed);
|
|
const desc =
|
|
taskSet === "quota"
|
|
? `Invite ${formatMetric(threshold)} friends to claim ${formatMetric(goldAmount)} coins`
|
|
: `Reach ${formatMetric(threshold)} recharge coins to claim ${formatMetric(goldAmount)} coins`;
|
|
|
|
return {
|
|
title: `Milestone ${index + 1}`,
|
|
desc,
|
|
buttonLabel: claimed ? "Claimed" : achieved ? "Claim" : "Incomplete",
|
|
disabled: claimed || !achieved,
|
|
claimed,
|
|
achieved,
|
|
ruleId: trimValue(item?.ruleId),
|
|
};
|
|
});
|
|
}
|
|
|
|
function normalizeRankList(list) {
|
|
if (!Array.isArray(list)) {
|
|
return [];
|
|
}
|
|
|
|
return list.map((item, index) => {
|
|
const rank = Number(item?.rank || index + 1);
|
|
const inviteCount = Number(item?.inviteCount || 0);
|
|
const rewardGoldCoins = Number(item?.rewardGoldCoins || 0);
|
|
const userName = trimValue(item?.nickname) || `User ${trimValue(item?.userId) || rank}`;
|
|
const scoreParts = [`${formatMetric(inviteCount)} people`];
|
|
if (rewardGoldCoins > 0) {
|
|
scoreParts.push(`${formatMetric(rewardGoldCoins)} coins`);
|
|
}
|
|
|
|
return {
|
|
rank,
|
|
name: userName,
|
|
avatar: trimValue(item?.avatar) || DEFAULT_AVATAR,
|
|
inviteCountText: `${formatMetric(inviteCount)} person`,
|
|
rewardText: formatMetric(rewardGoldCoins),
|
|
score: scoreParts.join(" · "),
|
|
};
|
|
});
|
|
}
|
|
|
|
function markTaskClaimed(ruleId) {
|
|
const targetRuleId = trimValue(ruleId);
|
|
["quota", "transport"].forEach((taskSet) => {
|
|
state.tasks[taskSet] = (state.tasks[taskSet] || []).map((item) => {
|
|
if (trimValue(item.ruleId) !== targetRuleId) {
|
|
return item;
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
claimed: true,
|
|
disabled: true,
|
|
buttonLabel: "Claimed",
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
function applyRankData(body) {
|
|
if (!body || typeof body !== "object") {
|
|
state.rankItems = [];
|
|
return;
|
|
}
|
|
|
|
state.rankItems = normalizeRankList(body.items);
|
|
}
|
|
|
|
function applyHomeData(body) {
|
|
if (!body || typeof body !== "object") {
|
|
return;
|
|
}
|
|
|
|
const stats = body.stats || {};
|
|
if (body.serverTimeMs) {
|
|
state.serverOffsetMs = Number(body.serverTimeMs) - Date.now();
|
|
}
|
|
if (body.periodEndAtMs) {
|
|
state.countdownEndAtMs = Number(body.periodEndAtMs);
|
|
}
|
|
state.inviteLink = trimValue(body.inviteLink) || state.inviteLink;
|
|
state.inviteCode = trimValue(body.inviteCode) || state.inviteCode;
|
|
state.downloadUrl = trimValue(body.downloadUrl) || state.downloadUrl;
|
|
state.invitees = stats.totalInvitedUsers ?? state.invitees;
|
|
state.eligibleVoters = stats.totalValidUsers ?? state.eligibleVoters;
|
|
state.rechargeTotal = stats.totalRechargeCoins ?? state.rechargeTotal;
|
|
state.myRewards = stats.rewardGoldCoins ?? state.myRewards;
|
|
|
|
const inviterBindReward = body.inviterBindReward?.goldAmount;
|
|
if (inviterBindReward != null) {
|
|
state.inviterBindReward = inviterBindReward;
|
|
}
|
|
|
|
const rechargeReward =
|
|
body.inviteeRechargeRules?.[0]?.goldAmount ??
|
|
body.inviterTotalRechargeTasks?.[0]?.goldAmount;
|
|
if (rechargeReward != null) {
|
|
state.rechargeReward = rechargeReward;
|
|
}
|
|
|
|
state.tasks.quota = normalizeTaskList(body.inviterValidCountTasks, "quota");
|
|
state.tasks.transport = normalizeTaskList(body.inviterTotalRechargeTasks, "transport");
|
|
|
|
const shareTitle = trimValue(body.shareTitle);
|
|
if (shareTitle) {
|
|
document.title = shareTitle;
|
|
}
|
|
}
|
|
|
|
function applyLandingData(body) {
|
|
if (!body || typeof body !== "object") {
|
|
return;
|
|
}
|
|
|
|
state.friendName = trimValue(body.inviterNickname) || state.friendName;
|
|
state.inviterAvatar = trimValue(body.inviterAvatar) || DEFAULT_AVATAR;
|
|
state.downloadUrl = trimValue(body.downloadUrl) || state.downloadUrl;
|
|
|
|
const inviteCode = trimValue(body.inviteCode);
|
|
if (inviteCode) {
|
|
state.landingCode = inviteCode;
|
|
state.inviteCode = inviteCode;
|
|
}
|
|
|
|
const shareTitle = trimValue(body.shareTitle);
|
|
if (shareTitle) {
|
|
document.title = shareTitle;
|
|
}
|
|
}
|
|
|
|
function buildDownloadURL(downloadUrl, inviteCode) {
|
|
const rawURL = trimValue(downloadUrl);
|
|
const code = trimValue(inviteCode);
|
|
if (!rawURL || !code) {
|
|
return rawURL;
|
|
}
|
|
|
|
try {
|
|
const url = new URL(rawURL, window.location.href);
|
|
if (url.hostname.includes("play.google.com")) {
|
|
const referrer = new URLSearchParams(url.searchParams.get("referrer") || "");
|
|
setPlayInstallReferrer(referrer, code);
|
|
removeTopLevelInviteCodeParams(url.searchParams);
|
|
url.searchParams.set("referrer", referrer.toString());
|
|
return url.toString();
|
|
}
|
|
|
|
setInviteCodeParams(url.searchParams, code);
|
|
return url.toString();
|
|
} catch {
|
|
const separator = rawURL.includes("?") ? "&" : "?";
|
|
const params = new URLSearchParams();
|
|
setInviteCodeParams(params, code);
|
|
return `${rawURL}${separator}${params.toString()}`;
|
|
}
|
|
}
|
|
|
|
function setInviteCodeParams(searchParams, code) {
|
|
searchParams.set("code", code);
|
|
searchParams.set("invitationCode", code);
|
|
searchParams.set("inviteCode", code);
|
|
}
|
|
|
|
function setPlayInstallReferrer(searchParams, code) {
|
|
searchParams.set("invite_code", code);
|
|
searchParams.set("source", "share");
|
|
}
|
|
|
|
function removeTopLevelInviteCodeParams(searchParams) {
|
|
["code", "invitationCode", "inviteCode", "invite_code"].forEach((name) => {
|
|
searchParams.delete(name);
|
|
});
|
|
}
|
|
|
|
async function fetchHomeData() {
|
|
if (!state.authorization) {
|
|
return false;
|
|
}
|
|
|
|
const body = await requestJSON("/app/h5/invite-campaign/home", {
|
|
headers: {
|
|
Authorization: state.authorization,
|
|
},
|
|
});
|
|
applyHomeData(body);
|
|
return true;
|
|
}
|
|
|
|
async function fetchRankData() {
|
|
if (!state.authorization) {
|
|
return false;
|
|
}
|
|
|
|
const body = await requestJSON("/app/h5/invite-campaign/rank", {
|
|
headers: {
|
|
Authorization: state.authorization,
|
|
},
|
|
searchParams: {
|
|
limit: 50,
|
|
},
|
|
});
|
|
applyRankData(body);
|
|
return true;
|
|
}
|
|
|
|
async function fetchLandingData() {
|
|
if (!state.landingCode) {
|
|
return false;
|
|
}
|
|
|
|
const body = await requestJSON("/public/h5/invite-campaign/landing", {
|
|
searchParams: {
|
|
code: state.landingCode,
|
|
},
|
|
});
|
|
applyLandingData(body);
|
|
return true;
|
|
}
|
|
|
|
async function refreshPageData() {
|
|
try {
|
|
if (state.view === "gift") {
|
|
await fetchLandingData();
|
|
} else {
|
|
const results = await Promise.allSettled([
|
|
fetchHomeData(),
|
|
fetchRankData(),
|
|
]);
|
|
const rejected = results.find((item) => item.status === "rejected");
|
|
if (rejected) {
|
|
throw rejected.reason;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const message = trimValue(error?.message);
|
|
if (message) {
|
|
showToast(message);
|
|
}
|
|
} finally {
|
|
renderStats();
|
|
renderTasks();
|
|
renderRankList();
|
|
}
|
|
}
|
|
|
|
async function claimTask(ruleId) {
|
|
const normalizedRuleId = trimValue(ruleId);
|
|
if (!normalizedRuleId) {
|
|
return;
|
|
}
|
|
|
|
if (!state.authorization) {
|
|
showToast("Token is missing");
|
|
return;
|
|
}
|
|
|
|
state.pendingClaimRuleId = normalizedRuleId;
|
|
renderTasks();
|
|
|
|
try {
|
|
const body = await requestJSON("/app/h5/invite-campaign/tasks/claim", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: state.authorization,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ ruleId: normalizedRuleId }),
|
|
});
|
|
|
|
markTaskClaimed(normalizedRuleId);
|
|
showToast(body?.alreadyClaimed ? "Already claimed" : "Claim succeeded");
|
|
await fetchHomeData().catch(() => {});
|
|
await fetchRankData().catch(() => {});
|
|
} catch (error) {
|
|
showToast(trimValue(error?.message) || "Claim failed");
|
|
} finally {
|
|
state.pendingClaimRuleId = "";
|
|
renderStats();
|
|
renderTasks();
|
|
renderRankList();
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
els.taskTabs.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
state.taskSet = normalizeTaskSet(button.getAttribute("data-task-set"));
|
|
renderTasks();
|
|
});
|
|
});
|
|
|
|
els.navButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const nextView = button.getAttribute("data-nav-view") || "invite";
|
|
setView(nextView);
|
|
if (nextView === "rank") {
|
|
fetchRankData()
|
|
.then(renderRankList)
|
|
.catch((error) => showToast(trimValue(error?.message) || "Rank load failed"));
|
|
}
|
|
});
|
|
});
|
|
|
|
els.copyButtons.forEach((button) => {
|
|
button.addEventListener("click", async () => {
|
|
const target = button.getAttribute("data-copy-target");
|
|
const value = target === "invite-code" ? state.inviteCode : currentInviteShareCopyText();
|
|
|
|
try {
|
|
await copyText(trimValue(value));
|
|
showToast(target === "invite-code" ? "Invitation code copied" : "Invitation link copied");
|
|
} catch (error) {
|
|
showToast("Copy failed");
|
|
}
|
|
});
|
|
});
|
|
|
|
els.taskList.addEventListener("click", (event) => {
|
|
const button = event.target.closest("[data-rule-id]");
|
|
if (!(button instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
const ruleId = trimValue(button.getAttribute("data-rule-id"));
|
|
if (!ruleId || button.disabled) {
|
|
return;
|
|
}
|
|
|
|
claimTask(ruleId);
|
|
});
|
|
|
|
els.downloadButton.addEventListener("click", () => {
|
|
if (!state.downloadUrl) {
|
|
showToast("Download link is not configured");
|
|
return;
|
|
}
|
|
|
|
window.location.href = buildDownloadURL(state.downloadUrl, state.landingCode || state.inviteCode);
|
|
});
|
|
}
|
|
|
|
async function init() {
|
|
renderStats();
|
|
renderTasks();
|
|
renderRankList();
|
|
bindEvents();
|
|
setView(state.view);
|
|
setupCountdown();
|
|
await connectToApp();
|
|
await refreshPageData();
|
|
}
|
|
|
|
init();
|