添加邀请
This commit is contained in:
parent
cbc72e8489
commit
f42551294f
@ -47,19 +47,19 @@
|
|||||||
<article class="surface-card task-card">
|
<article class="surface-card task-card">
|
||||||
<div class="task-title" data-i18n="today_host_task">My task as a host today is:</div>
|
<div class="task-title" data-i18n="today_host_task">My task as a host today is:</div>
|
||||||
<div class="task-grid">
|
<div class="task-grid">
|
||||||
<div class="task-cell">
|
<!-- <div class="task-cell">
|
||||||
<div class="task-label" data-i18n="host_type">Host type</div>
|
<div class="task-label" data-i18n="host_type">Host type</div>
|
||||||
<div class="task-value" id="anchorType">Chat</div>
|
<div class="task-value" id="anchorType">Chat</div>
|
||||||
</div>
|
</div> -->
|
||||||
<div class="task-cell">
|
<div class="task-cell">
|
||||||
<div class="task-label" data-i18n="minutes">Minutes</div>
|
<div class="task-label" data-i18n="minutes">Minutes</div>
|
||||||
<div class="task-value"><span id="minutesProgress">0</span>/<span id="minutesTotal">120</span>
|
<div class="task-value"><span id="minutesProgress">0</span>/<span id="minutesTotal">120</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-cell">
|
<!-- <div class="task-cell">
|
||||||
<div class="task-label" data-i18n="gift_task">Gift Task</div>
|
<div class="task-label" data-i18n="gift_task">Gift Task</div>
|
||||||
<div class="task-value" id="giftTask">0</div>
|
<div class="task-value" id="giftTask">0</div>
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
const query = new URLSearchParams(window.location.search);
|
const query = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
const DEFAULT_INVITE_API_BASE = "https://jvapi.haiyihy.com/";
|
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 DEFAULT_AVATAR = "./assets/avatar-sample.png";
|
||||||
|
const DEFAULT_RESET_PERIOD_MS = 30 * 86400 * 1000;
|
||||||
const DEFAULT_TASK_NOTES = {
|
const DEFAULT_TASK_NOTES = {
|
||||||
transport: "(Only the highest-level bonuses may be awarded)",
|
transport: "(Only the highest-level bonuses may be awarded)",
|
||||||
quota: "(You'll receive a reward upon reaching each level)",
|
quota: "(You'll receive a reward upon reaching each level)",
|
||||||
@ -9,104 +14,30 @@ const DEFAULT_TASK_NOTES = {
|
|||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
view: "invite",
|
view: "invite",
|
||||||
inviteLink: "http://yourappbalbalanal",
|
inviteLink: "",
|
||||||
inviteCode: "123455678",
|
inviteCode: "",
|
||||||
invitees: "168",
|
invitees: "0",
|
||||||
eligibleVoters: "01",
|
eligibleVoters: "0",
|
||||||
rechargeTotal: "01",
|
rechargeTotal: "0",
|
||||||
myRewards: "12354",
|
myRewards: "0",
|
||||||
friendName: "Name",
|
friendName: "Name",
|
||||||
downloadUrl: "https://example.com/download",
|
downloadUrl: "",
|
||||||
endAt: "",
|
inviterBindReward: 0,
|
||||||
inviterBindReward: 2000,
|
rechargeReward: 0,
|
||||||
rechargeReward: 1000,
|
giftReward: 0,
|
||||||
giftReward: 1000,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const FALLBACK_TASKS = {
|
const FALLBACK_TASKS = {
|
||||||
transport: [
|
transport: [],
|
||||||
{
|
quota: [],
|
||||||
title: "Milestone 1",
|
|
||||||
desc: "Total invitee recharge reaches 49,999 coins",
|
|
||||||
buttonLabel: "Incomplete",
|
|
||||||
disabled: true,
|
|
||||||
claimed: false,
|
|
||||||
achieved: false,
|
|
||||||
ruleId: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Milestone 2",
|
|
||||||
desc: "Total invitee recharge reaches 99,999 coins",
|
|
||||||
buttonLabel: "Incomplete",
|
|
||||||
disabled: true,
|
|
||||||
claimed: false,
|
|
||||||
achieved: false,
|
|
||||||
ruleId: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Milestone 3",
|
|
||||||
desc: "Total invitee recharge reaches 149,999 coins",
|
|
||||||
buttonLabel: "Incomplete",
|
|
||||||
disabled: true,
|
|
||||||
claimed: false,
|
|
||||||
achieved: false,
|
|
||||||
ruleId: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
quota: [
|
|
||||||
{
|
|
||||||
title: "Milestone 1",
|
|
||||||
desc: "Invite 10 friends who have already made a payment",
|
|
||||||
buttonLabel: "Claim",
|
|
||||||
disabled: false,
|
|
||||||
claimed: false,
|
|
||||||
achieved: true,
|
|
||||||
ruleId: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Milestone 2",
|
|
||||||
desc: "Invite 20 friends who have already made a payment",
|
|
||||||
buttonLabel: "Incomplete",
|
|
||||||
disabled: true,
|
|
||||||
claimed: false,
|
|
||||||
achieved: false,
|
|
||||||
ruleId: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Milestone 3",
|
|
||||||
desc: "Invite 30 friends who have already made a payment",
|
|
||||||
buttonLabel: "Claim",
|
|
||||||
disabled: false,
|
|
||||||
claimed: false,
|
|
||||||
achieved: true,
|
|
||||||
ruleId: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_RANKS = Array.from({ length: 30 }, (_, index) => ({
|
|
||||||
rank: index + 1,
|
|
||||||
name:
|
|
||||||
[
|
|
||||||
"hanneem",
|
|
||||||
"layan",
|
|
||||||
"amira",
|
|
||||||
"sami",
|
|
||||||
"noura",
|
|
||||||
"huda",
|
|
||||||
"rami",
|
|
||||||
"selim",
|
|
||||||
"mira",
|
|
||||||
"yael",
|
|
||||||
][index % 10],
|
|
||||||
score: `${123 - Math.floor(index / 3)} people`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
view: normalizeView(query.get("view") || DEFAULTS.view),
|
view: resolveInitialView(),
|
||||||
apiBase: resolveAPIBase(),
|
apiBase: resolveAPIBase(),
|
||||||
|
apiPrefix: resolveAPIPrefix(),
|
||||||
authorization: resolveAuthorization(),
|
authorization: resolveAuthorization(),
|
||||||
landingCode: trimValue(query.get("code") || query.get("inviteCode")),
|
landingCode: resolveLandingCode(),
|
||||||
inviteLink: query.get("inviteLink") || DEFAULTS.inviteLink,
|
inviteLink: query.get("inviteLink") || DEFAULTS.inviteLink,
|
||||||
inviteCode: query.get("inviteCode") || DEFAULTS.inviteCode,
|
inviteCode: query.get("inviteCode") || DEFAULTS.inviteCode,
|
||||||
invitees: query.get("invitees") || DEFAULTS.invitees,
|
invitees: query.get("invitees") || DEFAULTS.invitees,
|
||||||
@ -124,6 +55,9 @@ const state = {
|
|||||||
transport: cloneTasks(FALLBACK_TASKS.transport),
|
transport: cloneTasks(FALLBACK_TASKS.transport),
|
||||||
quota: cloneTasks(FALLBACK_TASKS.quota),
|
quota: cloneTasks(FALLBACK_TASKS.quota),
|
||||||
},
|
},
|
||||||
|
rankItems: [],
|
||||||
|
serverOffsetMs: 0,
|
||||||
|
countdownEndAtMs: Date.now() + DEFAULT_RESET_PERIOD_MS,
|
||||||
countdownTimer: null,
|
countdownTimer: null,
|
||||||
pendingClaimRuleId: 0,
|
pendingClaimRuleId: 0,
|
||||||
};
|
};
|
||||||
@ -174,15 +108,81 @@ function trimValue(value) {
|
|||||||
return value == null ? "" : String(value).trim();
|
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("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) {
|
function normalizeBaseURL(base) {
|
||||||
const value = trimValue(base);
|
const value = trimValue(base);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return DEFAULT_INVITE_API_BASE;
|
return resolveDefaultAPIBase();
|
||||||
}
|
}
|
||||||
|
|
||||||
return value.endsWith("/") ? value : `${value}/`;
|
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() {
|
function resolveAPIBase() {
|
||||||
const queryBase = trimValue(query.get("apiBase"));
|
const queryBase = trimValue(query.get("apiBase"));
|
||||||
if (queryBase) {
|
if (queryBase) {
|
||||||
@ -193,7 +193,20 @@ function resolveAPIBase() {
|
|||||||
return normalizeBaseURL(window.__APP_INVITE_API_BASE__);
|
return normalizeBaseURL(window.__APP_INVITE_API_BASE__);
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeBaseURL(DEFAULT_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() {
|
function resolveAuthorization() {
|
||||||
@ -375,6 +388,22 @@ function renderTasks() {
|
|||||||
tab.classList.toggle("passive", !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
|
els.taskList.innerHTML = current
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const isLoading = Number(item.ruleId) > 0 && state.pendingClaimRuleId === Number(item.ruleId);
|
const isLoading = Number(item.ruleId) > 0 && state.pendingClaimRuleId === Number(item.ruleId);
|
||||||
@ -412,7 +441,25 @@ function renderTasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderRankList() {
|
function renderRankList() {
|
||||||
els.rankList.innerHTML = DEFAULT_RANKS.map((item) => {
|
const list = state.rankItems;
|
||||||
|
if (!Array.isArray(list) || list.length === 0) {
|
||||||
|
els.rankList.innerHTML = `
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.rankList.innerHTML = list.map((item) => {
|
||||||
const badgeMap = {
|
const badgeMap = {
|
||||||
1: "./assets/rank-top1.png",
|
1: "./assets/rank-top1.png",
|
||||||
2: "./assets/rank-top2.png",
|
2: "./assets/rank-top2.png",
|
||||||
@ -433,7 +480,7 @@ function renderRankList() {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
<img src="./assets/avatar-sample.png" alt="${escapeHtml(item.name)}" />
|
<img src="${escapeHtml(item.avatar || DEFAULT_AVATAR)}" alt="${escapeHtml(item.name)}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="rank-name">${escapeHtml(item.name)}</div>
|
<div class="rank-name">${escapeHtml(item.name)}</div>
|
||||||
<div class="rank-score">${escapeHtml(item.score)}</div>
|
<div class="rank-score">${escapeHtml(item.score)}</div>
|
||||||
@ -442,31 +489,13 @@ function renderRankList() {
|
|||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEndAt() {
|
|
||||||
const raw = query.get("endAt") || DEFAULTS.endAt;
|
|
||||||
if (!raw) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = Date.parse(raw);
|
|
||||||
if (Number.isNaN(timestamp)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pad(number) {
|
function pad(number) {
|
||||||
return String(number).padStart(2, "0");
|
return String(number).padStart(2, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCountdown() {
|
function updateCountdown() {
|
||||||
const endAt = parseEndAt();
|
const now = Date.now() + state.serverOffsetMs;
|
||||||
if (!endAt) {
|
const diff = Math.max(0, Number(state.countdownEndAtMs || 0) - now);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const diff = Math.max(0, endAt - Date.now());
|
|
||||||
const seconds = Math.floor(diff / 1000);
|
const seconds = Math.floor(diff / 1000);
|
||||||
const days = Math.floor(seconds / 86400);
|
const days = Math.floor(seconds / 86400);
|
||||||
const hours = Math.floor((seconds % 86400) / 3600);
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
@ -480,11 +509,6 @@ function updateCountdown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupCountdown() {
|
function setupCountdown() {
|
||||||
const endAt = parseEndAt();
|
|
||||||
if (!endAt) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCountdown();
|
updateCountdown();
|
||||||
state.countdownTimer = window.setInterval(updateCountdown, 1000);
|
state.countdownTimer = window.setInterval(updateCountdown, 1000);
|
||||||
}
|
}
|
||||||
@ -519,14 +543,7 @@ function extractErrorMessage(payload) {
|
|||||||
|
|
||||||
async function requestJSON(path, options = {}) {
|
async function requestJSON(path, options = {}) {
|
||||||
const { method = "GET", headers = {}, body, searchParams } = options;
|
const { method = "GET", headers = {}, body, searchParams } = options;
|
||||||
const url = new URL(path, state.apiBase);
|
const url = buildAPIURL(path, searchParams);
|
||||||
|
|
||||||
Object.entries(searchParams || {}).forEach(([key, value]) => {
|
|
||||||
const raw = trimValue(value);
|
|
||||||
if (raw) {
|
|
||||||
url.searchParams.set(key, raw);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
const response = await fetch(url.toString(), {
|
||||||
method,
|
method,
|
||||||
@ -548,6 +565,33 @@ async function requestJSON(path, options = {}) {
|
|||||||
return payload?.body || payload?.data || payload || null;
|
return payload?.body || payload?.data || payload || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function normalizeTaskList(list, taskSet) {
|
||||||
if (!Array.isArray(list) || list.length === 0) {
|
if (!Array.isArray(list) || list.length === 0) {
|
||||||
return cloneTasks(FALLBACK_TASKS[taskSet]);
|
return cloneTasks(FALLBACK_TASKS[taskSet]);
|
||||||
@ -560,7 +604,7 @@ function normalizeTaskList(list, taskSet) {
|
|||||||
const claimed = Boolean(item?.claimed);
|
const claimed = Boolean(item?.claimed);
|
||||||
const desc =
|
const desc =
|
||||||
taskSet === "quota"
|
taskSet === "quota"
|
||||||
? `Invite ${formatMetric(threshold)} valid friends to claim ${formatMetric(goldAmount)} coins`
|
? `Invite ${formatMetric(threshold)} friends to claim ${formatMetric(goldAmount)} coins`
|
||||||
: `Reach ${formatMetric(threshold)} recharge coins to claim ${formatMetric(goldAmount)} coins`;
|
: `Reach ${formatMetric(threshold)} recharge coins to claim ${formatMetric(goldAmount)} coins`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -575,6 +619,30 @@ function normalizeTaskList(list, taskSet) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
score: scoreParts.join(" · "),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function markTaskClaimed(ruleId) {
|
function markTaskClaimed(ruleId) {
|
||||||
["quota", "transport"].forEach((taskSet) => {
|
["quota", "transport"].forEach((taskSet) => {
|
||||||
state.tasks[taskSet] = (state.tasks[taskSet] || []).map((item) => {
|
state.tasks[taskSet] = (state.tasks[taskSet] || []).map((item) => {
|
||||||
@ -592,12 +660,27 @@ function markTaskClaimed(ruleId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyRankData(body) {
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
state.rankItems = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.rankItems = normalizeRankList(body.items);
|
||||||
|
}
|
||||||
|
|
||||||
function applyHomeData(body) {
|
function applyHomeData(body) {
|
||||||
if (!body || typeof body !== "object") {
|
if (!body || typeof body !== "object") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = body.stats || {};
|
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.inviteLink = trimValue(body.inviteLink) || state.inviteLink;
|
||||||
state.inviteCode = trimValue(body.inviteCode) || state.inviteCode;
|
state.inviteCode = trimValue(body.inviteCode) || state.inviteCode;
|
||||||
state.downloadUrl = trimValue(body.downloadUrl) || state.downloadUrl;
|
state.downloadUrl = trimValue(body.downloadUrl) || state.downloadUrl;
|
||||||
@ -612,8 +695,8 @@ function applyHomeData(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rechargeReward =
|
const rechargeReward =
|
||||||
body.inviterTotalRechargeTasks?.[0]?.goldAmount ??
|
body.inviteeRechargeRules?.[0]?.goldAmount ??
|
||||||
body.inviteeRechargeRules?.[0]?.goldAmount;
|
body.inviterTotalRechargeTasks?.[0]?.goldAmount;
|
||||||
if (rechargeReward != null) {
|
if (rechargeReward != null) {
|
||||||
state.rechargeReward = rechargeReward;
|
state.rechargeReward = rechargeReward;
|
||||||
}
|
}
|
||||||
@ -640,6 +723,7 @@ function applyLandingData(body) {
|
|||||||
const inviteCode = trimValue(body.inviteCode);
|
const inviteCode = trimValue(body.inviteCode);
|
||||||
if (inviteCode) {
|
if (inviteCode) {
|
||||||
state.landingCode = inviteCode;
|
state.landingCode = inviteCode;
|
||||||
|
state.inviteCode = inviteCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shareTitle = trimValue(body.shareTitle);
|
const shareTitle = trimValue(body.shareTitle);
|
||||||
@ -648,12 +732,56 @@ function applyLandingData(body) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function fetchHomeData() {
|
||||||
if (!state.authorization) {
|
if (!state.authorization) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await requestJSON("/go/app/h5/invite-campaign/home", {
|
const body = await requestJSON("/app/h5/invite-campaign/home", {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: state.authorization,
|
Authorization: state.authorization,
|
||||||
},
|
},
|
||||||
@ -662,12 +790,29 @@ async function fetchHomeData() {
|
|||||||
return true;
|
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() {
|
async function fetchLandingData() {
|
||||||
if (!state.landingCode) {
|
if (!state.landingCode) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await requestJSON("/go/public/h5/invite-campaign/landing", {
|
const body = await requestJSON("/public/h5/invite-campaign/landing", {
|
||||||
searchParams: {
|
searchParams: {
|
||||||
code: state.landingCode,
|
code: state.landingCode,
|
||||||
},
|
},
|
||||||
@ -681,7 +826,10 @@ async function refreshPageData() {
|
|||||||
if (state.view === "gift") {
|
if (state.view === "gift") {
|
||||||
await fetchLandingData();
|
await fetchLandingData();
|
||||||
} else {
|
} else {
|
||||||
await fetchHomeData();
|
const hasHome = await fetchHomeData();
|
||||||
|
if (hasHome) {
|
||||||
|
await fetchRankData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = trimValue(error?.message);
|
const message = trimValue(error?.message);
|
||||||
@ -691,6 +839,7 @@ async function refreshPageData() {
|
|||||||
} finally {
|
} finally {
|
||||||
renderStats();
|
renderStats();
|
||||||
renderTasks();
|
renderTasks();
|
||||||
|
renderRankList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -708,7 +857,7 @@ async function claimTask(ruleId) {
|
|||||||
renderTasks();
|
renderTasks();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await requestJSON("/go/app/h5/invite-campaign/tasks/claim", {
|
const body = await requestJSON("/app/h5/invite-campaign/tasks/claim", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: state.authorization,
|
Authorization: state.authorization,
|
||||||
@ -720,12 +869,14 @@ async function claimTask(ruleId) {
|
|||||||
markTaskClaimed(ruleId);
|
markTaskClaimed(ruleId);
|
||||||
showToast(body?.alreadyClaimed ? "Already claimed" : "Claim succeeded");
|
showToast(body?.alreadyClaimed ? "Already claimed" : "Claim succeeded");
|
||||||
await fetchHomeData().catch(() => {});
|
await fetchHomeData().catch(() => {});
|
||||||
|
await fetchRankData().catch(() => {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(trimValue(error?.message) || "Claim failed");
|
showToast(trimValue(error?.message) || "Claim failed");
|
||||||
} finally {
|
} finally {
|
||||||
state.pendingClaimRuleId = 0;
|
state.pendingClaimRuleId = 0;
|
||||||
renderStats();
|
renderStats();
|
||||||
renderTasks();
|
renderTasks();
|
||||||
|
renderRankList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -739,7 +890,13 @@ function bindEvents() {
|
|||||||
|
|
||||||
els.navButtons.forEach((button) => {
|
els.navButtons.forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
setView(button.getAttribute("data-nav-view") || "invite");
|
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"));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -777,7 +934,7 @@ function bindEvents() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = state.downloadUrl;
|
window.location.href = buildDownloadURL(state.downloadUrl, state.landingCode || state.inviteCode);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
h5/app-invite/assets/btn-bg.png
Normal file
BIN
h5/app-invite/assets/btn-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
h5/app-invite/assets/newuser.png
Normal file
BIN
h5/app-invite/assets/newuser.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 306 KiB |
BIN
h5/app-invite/assets/p1.png
Normal file
BIN
h5/app-invite/assets/p1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 252 KiB |
BIN
h5/app-invite/assets/p2.png
Normal file
BIN
h5/app-invite/assets/p2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
BIN
h5/app-invite/assets/p3.png
Normal file
BIN
h5/app-invite/assets/p3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
BIN
h5/app-invite/assets/p4.png
Normal file
BIN
h5/app-invite/assets/p4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
@ -4,7 +4,7 @@ import { extname, join, normalize } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const rootDir = fileURLToPath(new URL(".", import.meta.url));
|
const rootDir = fileURLToPath(new URL(".", import.meta.url));
|
||||||
const port = Number(process.env.INVITE_TEST_PORT || 65248);
|
const port = Number(process.env.INVITE_TEST_PORT || 8800);
|
||||||
const apiTarget = String(process.env.INVITE_API_TARGET || "http://127.0.0.1:2900").replace(/\/$/, "");
|
const apiTarget = String(process.env.INVITE_API_TARGET || "http://127.0.0.1:2900").replace(/\/$/, "");
|
||||||
|
|
||||||
const mimeTypes = {
|
const mimeTypes = {
|
||||||
@ -18,14 +18,24 @@ const mimeTypes = {
|
|||||||
".svg": "image/svg+xml",
|
".svg": "image/svg+xml",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
"access-control-allow-methods": "GET,POST,OPTIONS",
|
||||||
|
"access-control-allow-headers": "authorization,content-type,accept",
|
||||||
|
};
|
||||||
|
|
||||||
|
function withCORS(headers = {}) {
|
||||||
|
return { ...corsHeaders, ...headers };
|
||||||
|
}
|
||||||
|
|
||||||
function sendText(response, status, text) {
|
function sendText(response, status, text) {
|
||||||
response.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
|
response.writeHead(status, withCORS({ "content-type": "text/plain; charset=utf-8" }));
|
||||||
response.end(text);
|
response.end(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function serveStatic(request, response) {
|
function serveStatic(request, response) {
|
||||||
const requestPath = new URL(request.url, "http://localhost").pathname;
|
const requestPath = new URL(request.url, "http://localhost").pathname;
|
||||||
const pathname = requestPath === "/" ? "/dev-test.html" : requestPath;
|
const pathname = normalizeStaticPath(requestPath);
|
||||||
const filePath = normalize(join(rootDir, pathname));
|
const filePath = normalize(join(rootDir, pathname));
|
||||||
if (!filePath.startsWith(rootDir)) {
|
if (!filePath.startsWith(rootDir)) {
|
||||||
sendText(response, 403, "Forbidden");
|
sendText(response, 403, "Forbidden");
|
||||||
@ -39,6 +49,7 @@ function serveStatic(request, response) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
response.writeHead(200, {
|
response.writeHead(200, {
|
||||||
|
...corsHeaders,
|
||||||
"content-type": mimeTypes[extname(filePath)] || "application/octet-stream",
|
"content-type": mimeTypes[extname(filePath)] || "application/octet-stream",
|
||||||
"content-length": stat.size,
|
"content-length": stat.size,
|
||||||
});
|
});
|
||||||
@ -48,6 +59,26 @@ function serveStatic(request, response) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStaticPath(requestPath) {
|
||||||
|
if (requestPath === "/") {
|
||||||
|
return "/dev-test.html";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestPath === "/public/h5/invite-campaign/landing") {
|
||||||
|
return "/landing.html";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestPath === "/h5/app-invite" || requestPath === "/h5/app-invite/") {
|
||||||
|
return "/index.html";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestPath.startsWith("/h5/app-invite/")) {
|
||||||
|
return requestPath.replace(/^\/h5\/app-invite/, "") || "/index.html";
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestPath;
|
||||||
|
}
|
||||||
|
|
||||||
async function proxyAPI(request, response) {
|
async function proxyAPI(request, response) {
|
||||||
const incomingURL = new URL(request.url, "http://localhost");
|
const incomingURL = new URL(request.url, "http://localhost");
|
||||||
const targetURL = new URL(incomingURL.pathname.replace(/^\/api/, "") + incomingURL.search, apiTarget);
|
const targetURL = new URL(incomingURL.pathname.replace(/^\/api/, "") + incomingURL.search, apiTarget);
|
||||||
@ -66,6 +97,7 @@ async function proxyAPI(request, response) {
|
|||||||
duplex: "half",
|
duplex: "half",
|
||||||
});
|
});
|
||||||
response.writeHead(upstream.status, {
|
response.writeHead(upstream.status, {
|
||||||
|
...corsHeaders,
|
||||||
"content-type": upstream.headers.get("content-type") || "application/octet-stream",
|
"content-type": upstream.headers.get("content-type") || "application/octet-stream",
|
||||||
});
|
});
|
||||||
if (upstream.body) {
|
if (upstream.body) {
|
||||||
@ -75,7 +107,7 @@ async function proxyAPI(request, response) {
|
|||||||
}
|
}
|
||||||
response.end();
|
response.end();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
response.writeHead(502, { "content-type": "application/json; charset=utf-8" });
|
response.writeHead(502, withCORS({ "content-type": "application/json; charset=utf-8" }));
|
||||||
response.end(JSON.stringify({
|
response.end(JSON.stringify({
|
||||||
status: false,
|
status: false,
|
||||||
code: "proxy_error",
|
code: "proxy_error",
|
||||||
@ -85,12 +117,19 @@ async function proxyAPI(request, response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createServer((request, response) => {
|
createServer((request, response) => {
|
||||||
|
if (request.method === "OPTIONS") {
|
||||||
|
response.writeHead(204, corsHeaders);
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (request.url?.startsWith("/api/") || request.url === "/api") {
|
if (request.url?.startsWith("/api/") || request.url === "/api") {
|
||||||
proxyAPI(request, response);
|
proxyAPI(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
serveStatic(request, response);
|
serveStatic(request, response);
|
||||||
}).listen(port, "127.0.0.1", () => {
|
}).listen(port, "127.0.0.1", () => {
|
||||||
|
console.log(`Invite H5 page: http://127.0.0.1:${port}/index.html`);
|
||||||
console.log(`Invite dev test page: http://127.0.0.1:${port}/dev-test.html`);
|
console.log(`Invite dev test page: http://127.0.0.1:${port}/dev-test.html`);
|
||||||
console.log(`Proxy target: ${apiTarget}`);
|
console.log(`Proxy target: ${apiTarget}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -358,7 +358,7 @@
|
|||||||
邀请链接
|
邀请链接
|
||||||
<input id="inviteLinkOutput" readonly />
|
<input id="inviteLinkOutput" readonly />
|
||||||
</label>
|
</label>
|
||||||
<div class="small" id="countdownText">月度倒计时未加载</div>
|
<div class="small" id="countdownText">30天任务倒计时未加载</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -743,20 +743,21 @@
|
|||||||
|
|
||||||
function startCountdown(home) {
|
function startCountdown(home) {
|
||||||
window.clearInterval(state.countdownTimer);
|
window.clearInterval(state.countdownTimer);
|
||||||
const endAt = Number(home.periodEndAtMs || 0);
|
|
||||||
const serverTime = Number(home.serverTimeMs || Date.now());
|
const serverTime = Number(home.serverTimeMs || Date.now());
|
||||||
|
const periodEndAt = Number(home.periodEndAtMs || (serverTime + 30 * 86400 * 1000));
|
||||||
const offset = serverTime - Date.now();
|
const offset = serverTime - Date.now();
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const diff = Math.max(0, endAt - (Date.now() + offset));
|
const now = Date.now() + offset;
|
||||||
|
const diff = Math.max(0, periodEndAt - now);
|
||||||
const seconds = Math.floor(diff / 1000);
|
const seconds = Math.floor(diff / 1000);
|
||||||
const days = Math.floor(seconds / 86400);
|
const days = Math.floor(seconds / 86400);
|
||||||
const hours = Math.floor((seconds % 86400) / 3600);
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
const secs = seconds % 60;
|
const secs = seconds % 60;
|
||||||
els.countdownText.textContent =
|
els.countdownText.textContent =
|
||||||
"当前月 " + (home.monthKey || "--") + ",距离重置:" +
|
"30天任务周期距离重置:" +
|
||||||
days + "天 " + pad(hours) + ":" + pad(minutes) + ":" + pad(secs) +
|
days + "天 " + pad(hours) + ":" + pad(minutes) + ":" + pad(secs) +
|
||||||
",periodEndAtMs=" + endAt;
|
",周期Key: " + escapeHtml(home.periodKey || home.monthKey || "--");
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
state.countdownTimer = window.setInterval(tick, 1000);
|
state.countdownTimer = window.setInterval(tick, 1000);
|
||||||
|
|||||||
@ -217,6 +217,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reward-amount {
|
.reward-amount {
|
||||||
|
z-index: 999;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 27px;
|
top: 27px;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
|
|||||||
400
h5/app-invite/landing.html
Normal file
400
h5/app-invite/landing.html
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<title>Invite Gift</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--page-width: 420px;
|
||||||
|
--text-primary: #fff8de;
|
||||||
|
--text-muted: rgba(255, 244, 208, 0.78);
|
||||||
|
--panel-top: url("./assets/panel-top.png");
|
||||||
|
--panel-middle: url("./assets/panel-middle.png");
|
||||||
|
--panel-bottom: url("./assets/panel-bottom.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #071f16;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow-x: hidden;
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, var(--page-width));
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #092616;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #092616;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg img {
|
||||||
|
width: min(195vw, 820px);
|
||||||
|
max-width: none;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-height: max(100vh, min(990px, 235.714vw));
|
||||||
|
padding: calc(env(safe-area-inset-top) + 45px) 14px calc(env(safe-area-inset-bottom) + 16px);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(1, 12, 8, 0.02) 0%, rgba(1, 16, 9, 0.12) 38%, rgba(1, 18, 9, 0.34) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitee-avatar {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 2px solid #fff0bc;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(113, 54, 23, 0.7),
|
||||||
|
0 10px 20px rgba(0, 0, 0, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitee-avatar img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
max-width: 330px;
|
||||||
|
margin: 14px auto 0;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 0.97;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: -0.35px;
|
||||||
|
color: #ffd98f;
|
||||||
|
text-shadow:
|
||||||
|
0 1px 0 #7d3c17,
|
||||||
|
0 -1px 0 #7d3c17,
|
||||||
|
1px 0 0 #7d3c17,
|
||||||
|
-1px 0 0 #7d3c17,
|
||||||
|
0 3px 9px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 13px auto 27px;
|
||||||
|
max-width: 350px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.22;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
color: #ffe2a3;
|
||||||
|
text-shadow:
|
||||||
|
0 1px 0 #714014,
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ornate-panel {
|
||||||
|
position: relative;
|
||||||
|
background-color: rgba(9, 53, 14, 0.95);
|
||||||
|
background-image: var(--panel-top), var(--panel-bottom), var(--panel-middle);
|
||||||
|
background-position: top center, bottom center, top center;
|
||||||
|
background-size: 100% auto, 100% auto, 100% auto;
|
||||||
|
background-repeat: no-repeat, no-repeat, repeat-y;
|
||||||
|
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.23);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-panel {
|
||||||
|
height: 243px;
|
||||||
|
padding: 19px 16px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.15;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff8de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-subtitle {
|
||||||
|
margin: 6px 0 21px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #df956a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward {
|
||||||
|
position: relative;
|
||||||
|
/* width: 112px; */
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-amount {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 5px;
|
||||||
|
left: 152px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff3d3;
|
||||||
|
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-orb {
|
||||||
|
position: relative;
|
||||||
|
width: 112px;
|
||||||
|
height: 112px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, rgba(255, 240, 180, 0.48) 0%, rgba(255, 240, 180, 0.08) 58%, transparent 72%),
|
||||||
|
rgba(255, 255, 255, 0.02);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 245, 209, 0.3),
|
||||||
|
0 0 28px rgba(255, 230, 157, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-orb img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-note {
|
||||||
|
margin: 8px auto 0;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.38;
|
||||||
|
color: #efe2bb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-grid {
|
||||||
|
--phone-card-width: min(49.286vw, 207px);
|
||||||
|
--phone-gap: min(5.714vw, 24px);
|
||||||
|
margin-top: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-track {
|
||||||
|
display: flex;
|
||||||
|
width: max-content;
|
||||||
|
gap: var(--phone-gap);
|
||||||
|
animation: phone-scroll 18s linear infinite;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-grid:active .phone-track {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-card {
|
||||||
|
flex: 0 0 var(--phone-card-width);
|
||||||
|
border-radius: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-card img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes phone-scroll {
|
||||||
|
to {
|
||||||
|
transform: translate3d(
|
||||||
|
calc(
|
||||||
|
0px - var(--phone-card-width) - var(--phone-gap) -
|
||||||
|
var(--phone-card-width) - var(--phone-gap) -
|
||||||
|
var(--phone-card-width) - var(--phone-gap) -
|
||||||
|
var(--phone-card-width) - var(--phone-gap)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.phone-track {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button {
|
||||||
|
position: fixed;
|
||||||
|
/* top: min(865px, 205.952vw); */
|
||||||
|
left: 14px;
|
||||||
|
right: 14px;
|
||||||
|
z-index: 4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: min(64px, 15.238vw);
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
background: center / 100% 100% no-repeat url("./assets/btn-bg.png");
|
||||||
|
bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button span {
|
||||||
|
color: #ffe8bd;
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
text-shadow:
|
||||||
|
0 1px 0 rgba(120, 62, 20, 0.85),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 24px;
|
||||||
|
z-index: 20;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(2, 16, 6, 0.88);
|
||||||
|
color: #fff8de;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(14px);
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<div class="bg" aria-hidden="true">
|
||||||
|
<img src="./assets/gift-bg.png" alt="" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="content">
|
||||||
|
<div class="invitee-avatar">
|
||||||
|
<img id="inviterAvatar" src="./assets/avatar-sample.png" alt="Inviter avatar" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="title" id="landingTitle">Your friend [Name]<br />has invited you to join</h1>
|
||||||
|
<p class="subtitle">Join him to receive an exclusive welcome gift</p>
|
||||||
|
|
||||||
|
<div class="ornate-panel gift-panel">
|
||||||
|
<p class="panel-title">Your Personal Gift</p>
|
||||||
|
<p class="panel-subtitle">(This is a special gift from your friend)</p>
|
||||||
|
<div class="reward">
|
||||||
|
<div class="reward-amount" id="giftRewardAmount">+1000</div>
|
||||||
|
<div class="reward-orb">
|
||||||
|
<img src="./assets/reward-bag.png" alt="Personal gift" />
|
||||||
|
</div>
|
||||||
|
<div style="width: 80px;margin-left: 25px;">
|
||||||
|
<span style="
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff3d3;
|
||||||
|
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.34);">+</span>
|
||||||
|
<img style="width: 100%;margin-left: 40px;" src="./assets/newuser.png" alt="" srcset="">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="gift-note">(Available only through this invitation link! Don't miss out!)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="phone-grid" aria-label="App preview carousel">
|
||||||
|
<div class="phone-track">
|
||||||
|
<div class="phone-card">
|
||||||
|
<img src="./assets/phone-gift.png" alt="App home preview" />
|
||||||
|
</div>
|
||||||
|
<div class="phone-card">
|
||||||
|
<img src="./assets/phone-home.png" alt="Gift preview" />
|
||||||
|
</div>
|
||||||
|
<div class="phone-card">
|
||||||
|
<img src="./assets/p3.png" alt="App reward preview" />
|
||||||
|
</div>
|
||||||
|
<div class="phone-card">
|
||||||
|
<img src="./assets/p1.png" alt="App activity preview" />
|
||||||
|
</div>
|
||||||
|
<div class="phone-card" aria-hidden="true">
|
||||||
|
<img src="./assets/phone-gift.png" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="phone-card" aria-hidden="true">
|
||||||
|
<img src="./assets/phone-home.png" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="phone-card" aria-hidden="true">
|
||||||
|
<img src="./assets/p3.png" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="phone-card" aria-hidden="true">
|
||||||
|
<img src="./assets/p1.png" alt="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="download-button"
|
||||||
|
id="downloadButton"
|
||||||
|
aria-label="Download now and claim your gift"
|
||||||
|
>
|
||||||
|
<span>Download now and claim your gift</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="toast" id="pageToast" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<script type="module" src="./landing.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
299
h5/app-invite/landing.js
Normal file
299
h5/app-invite/landing.js
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
const query = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
const LOCAL_PROXY_API_BASE = "/api/";
|
||||||
|
const LOCAL_FILE_PROXY_API_BASE = "http://127.0.0.1:8800/api/";
|
||||||
|
const LOCAL_INVITE_API_BASE = "http://127.0.0.1:2900/";
|
||||||
|
const ONLINE_INVITE_API_BASE = "https://jvapi.haiyihy.com/";
|
||||||
|
const ONLINE_INVITE_API_PREFIX = "/go";
|
||||||
|
const DEFAULT_AVATAR = "./assets/avatar-sample.png";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
apiBase: resolveAPIBase(),
|
||||||
|
apiPrefix: resolveAPIPrefix(),
|
||||||
|
inviteCode: resolveInviteCode(),
|
||||||
|
friendName: trimValue(query.get("friendName")) || "Name",
|
||||||
|
inviterAvatar: trimValue(query.get("inviterAvatar")) || DEFAULT_AVATAR,
|
||||||
|
giftReward: Number(query.get("giftReward") || 1000),
|
||||||
|
downloadUrl: trimValue(query.get("downloadUrl")),
|
||||||
|
};
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
title: document.getElementById("landingTitle"),
|
||||||
|
avatar: document.getElementById("inviterAvatar"),
|
||||||
|
giftRewardAmount: document.getElementById("giftRewardAmount"),
|
||||||
|
downloadButton: document.getElementById("downloadButton"),
|
||||||
|
toast: document.getElementById("pageToast"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function trimValue(value) {
|
||||||
|
return value == null ? "" : String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInviteCode() {
|
||||||
|
return trimValue(
|
||||||
|
query.get("code") ||
|
||||||
|
query.get("landingCode") ||
|
||||||
|
query.get("invitationCode") ||
|
||||||
|
query.get("inviteCode"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalHost(hostname = window.location.hostname) {
|
||||||
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsoluteURL(value) {
|
||||||
|
return /^https?:\/\//i.test(trimValue(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOnlineAPIBase(base) {
|
||||||
|
try {
|
||||||
|
return new URL(base, window.location.href).origin === new URL(ONLINE_INVITE_API_BASE).origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseURL(base) {
|
||||||
|
const value = trimValue(base);
|
||||||
|
const resolved = value || resolveDefaultAPIBase();
|
||||||
|
return resolved.endsWith("/") ? resolved : `${resolved}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 escapeHtml(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, (match) => {
|
||||||
|
const entityMap = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
};
|
||||||
|
return entityMap[match] || match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 extractErrorMessage(payload) {
|
||||||
|
return (
|
||||||
|
payload?.errorMsg ||
|
||||||
|
payload?.message ||
|
||||||
|
payload?.errorCodeName ||
|
||||||
|
payload?.code ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJSON(path, options = {}) {
|
||||||
|
const { searchParams } = options;
|
||||||
|
const response = await fetch(buildAPIURL(path, searchParams).toString(), {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
const payload = contentType.includes("application/json") ? await response.json() : null;
|
||||||
|
|
||||||
|
if (!response.ok || payload?.status === false) {
|
||||||
|
throw new Error(extractErrorMessage(payload) || `Request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload?.body || payload?.data || payload || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignedReward(value) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
return `+${new Intl.NumberFormat("en-US").format(number)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
els.title.innerHTML = `Your friend [${escapeHtml(state.friendName)}]<br />has invited you to join`;
|
||||||
|
els.avatar.src = trimValue(state.inviterAvatar) || DEFAULT_AVATAR;
|
||||||
|
els.giftRewardAmount.textContent = formatSignedReward(state.giftReward);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
state.giftReward = body.inviteeReward?.goldAmount ?? state.giftReward;
|
||||||
|
|
||||||
|
const inviteCode = trimValue(body.inviteCode);
|
||||||
|
if (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 loadLandingData() {
|
||||||
|
if (!state.inviteCode) {
|
||||||
|
showToast("Invitation code is missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await requestJSON("/public/h5/invite-campaign/landing", {
|
||||||
|
searchParams: {
|
||||||
|
code: state.inviteCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applyLandingData(body);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(trimValue(error?.message) || "Invite info load failed");
|
||||||
|
} finally {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
els.downloadButton.addEventListener("click", () => {
|
||||||
|
if (!state.downloadUrl) {
|
||||||
|
showToast("Download link is not configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = buildDownloadURL(state.downloadUrl, state.inviteCode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
render();
|
||||||
|
bindEvents();
|
||||||
|
await loadLandingData();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
@ -40,15 +40,15 @@
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="surface-card menu-card">
|
<article class="surface-card menu-card">
|
||||||
<button type="button" class="menu-row" data-route="/policy?from=BD">
|
<!-- <button type="button" class="menu-row" data-route="/policy?from=BD">
|
||||||
<span class="menu-label">
|
<span class="menu-label">
|
||||||
<img data-icon="policy" alt="">
|
<img data-icon="policy" alt="">
|
||||||
<span data-i18n="bd_policy_link">BD policy</span>
|
<span data-i18n="bd_policy_link">BD policy</span>
|
||||||
</span>
|
</span>
|
||||||
<img data-icon="arrow" class="flip-img" alt="">
|
<img data-icon="arrow" class="flip-img" alt="">
|
||||||
</button>
|
</button> -->
|
||||||
|
|
||||||
<div class="divider"></div>
|
<!-- <div class="divider"></div> -->
|
||||||
|
|
||||||
<button type="button" class="menu-row" data-route="/agency-list">
|
<button type="button" class="menu-row" data-route="/agency-list">
|
||||||
<span class="menu-label">
|
<span class="menu-label">
|
||||||
@ -77,7 +77,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<img data-icon="arrow" class="flip-img" alt="">
|
<img data-icon="arrow" class="flip-img" alt="">
|
||||||
</button>
|
</button>
|
||||||
|
<!--
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<button type="button" class="menu-row gift-permission" data-route="/bd-item-distribution" hidden>
|
<button type="button" class="menu-row gift-permission" data-route="/bd-item-distribution" hidden>
|
||||||
@ -86,7 +86,7 @@
|
|||||||
<span data-i18n="send_welcome_gift">Send welcome gift</span>
|
<span data-i18n="send_welcome_gift">Send welcome gift</span>
|
||||||
</span>
|
</span>
|
||||||
<img data-icon="arrow" class="flip-img" alt="">
|
<img data-icon="arrow" class="flip-img" alt="">
|
||||||
</button>
|
</button> -->
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export const API_BASE_URL = "https://jvapi.haiyihy.com";
|
|||||||
|
|
||||||
const text = {
|
const text = {
|
||||||
en: {
|
en: {
|
||||||
|
language: "Language",
|
||||||
bd_leader_center: "BD Leader Center",
|
bd_leader_center: "BD Leader Center",
|
||||||
available_salaries: "Available salaries",
|
available_salaries: "Available salaries",
|
||||||
bd: "BD",
|
bd: "BD",
|
||||||
@ -185,7 +186,7 @@ function resolveLanguage() {
|
|||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const hash = window.location.hash.includes("?") ? window.location.hash.split("?")[1] : "";
|
const hash = window.location.hash.includes("?") ? window.location.hash.split("?")[1] : "";
|
||||||
const hashParams = new URLSearchParams(hash);
|
const hashParams = new URLSearchParams(hash);
|
||||||
const value = params.get("lang") || hashParams.get("lang") || navigator.language || "en";
|
const value = params.get("lang") || hashParams.get("lang") || localStorage.getItem("preferred-language") || navigator.language || "en";
|
||||||
if (value.startsWith("ar")) return "ar";
|
if (value.startsWith("ar")) return "ar";
|
||||||
if (value.startsWith("tr")) return "tr";
|
if (value.startsWith("tr")) return "tr";
|
||||||
if (value.startsWith("bn")) return "bn";
|
if (value.startsWith("bn")) return "bn";
|
||||||
|
|||||||
@ -2,34 +2,17 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<meta
|
<title>Language</title>
|
||||||
name="viewport"
|
<link rel="stylesheet" href="../bd-static/shared.css">
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
<link rel="stylesheet" href="./language.css">
|
||||||
>
|
|
||||||
<meta name="format-detection" content="telephone=no">
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
||||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
|
||||||
<meta http-equiv="Pragma" content="no-cache">
|
|
||||||
<meta http-equiv="Expires" content="0">
|
|
||||||
<title>Yumi H5</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
window.__H5_ROUTE_PATH__ = "/language";
|
|
||||||
</script>
|
|
||||||
<script src="/assets/yumi-route-entry.js"></script>
|
|
||||||
<script type="module" crossorigin src="/js/index-CIAVq8iD-1776148658686.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/css/index-VRlNQ74k-1776148661679.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<main class="page language-page">
|
||||||
|
<section class="language-card">
|
||||||
|
<div class="language-list" id="languageList"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="./language.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
69
h5/language/language.css
Normal file
69
h5/language/language.css
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
.language-page {
|
||||||
|
background: #f4fbf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-card {
|
||||||
|
margin: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #d7eadf;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-list {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fcf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 54px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-bottom: 1px solid #e5efe8;
|
||||||
|
color: #244b37;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-row:active {
|
||||||
|
background: #e9f7ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-check {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #9fcdb0;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-check--active {
|
||||||
|
position: relative;
|
||||||
|
border-color: #32a96b;
|
||||||
|
background: #32a96b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-check--active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 5px;
|
||||||
|
top: 2px;
|
||||||
|
width: 5px;
|
||||||
|
height: 10px;
|
||||||
|
border: solid #fff;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .language-row {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
46
h5/language/language.js
Normal file
46
h5/language/language.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { applyLang, bindHeader } from "../bd-static/shared.js";
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ type: "en", value: "English", dir: "ltr" },
|
||||||
|
{ type: "ar", value: "اللغة العربية", dir: "rtl" },
|
||||||
|
{ type: "tr", value: "Türkçe", dir: "ltr" },
|
||||||
|
{ type: "bn", value: "বাংলা", dir: "ltr" }
|
||||||
|
];
|
||||||
|
|
||||||
|
bindHeader("language");
|
||||||
|
applyLang();
|
||||||
|
render();
|
||||||
|
|
||||||
|
function selectedType() {
|
||||||
|
const stored = localStorage.getItem("preferred-language");
|
||||||
|
if (languages.some((item) => item.type === stored)) return stored;
|
||||||
|
const navLang = (navigator.language || "en").split("-")[0];
|
||||||
|
return languages.some((item) => item.type === navLang) ? navLang : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const current = selectedType();
|
||||||
|
const list = document.getElementById("languageList");
|
||||||
|
list.innerHTML = languages.map((item) => `
|
||||||
|
<button class="language-row" type="button" data-lang="${item.type}">
|
||||||
|
<span>${item.value}</span>
|
||||||
|
<span class="selected-check ${current === item.type ? "selected-check--active" : ""}"></span>
|
||||||
|
</button>
|
||||||
|
`).join("");
|
||||||
|
list.querySelectorAll("[data-lang]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => switchLanguage(button.dataset.lang));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchLanguage(type) {
|
||||||
|
const target = languages.find((item) => item.type === type);
|
||||||
|
if (!target) return;
|
||||||
|
localStorage.setItem("preferred-language", target.type);
|
||||||
|
document.documentElement.lang = target.type;
|
||||||
|
document.documentElement.dir = target.dir;
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
window.history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.replace("/");
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user