添加邀请
This commit is contained in:
parent
cbc72e8489
commit
f42551294f
@ -47,19 +47,19 @@
|
||||
<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-grid">
|
||||
<div class="task-cell">
|
||||
<!-- <div class="task-cell">
|
||||
<div class="task-label" data-i18n="host_type">Host type</div>
|
||||
<div class="task-value" id="anchorType">Chat</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="task-cell">
|
||||
<div class="task-label" data-i18n="minutes">Minutes</div>
|
||||
<div class="task-value"><span id="minutesProgress">0</span>/<span id="minutesTotal">120</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-cell">
|
||||
<!-- <div class="task-cell">
|
||||
<div class="task-label" data-i18n="gift_task">Gift Task</div>
|
||||
<div class="task-value" id="giftTask">0</div>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
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_RESET_PERIOD_MS = 30 * 86400 * 1000;
|
||||
const DEFAULT_TASK_NOTES = {
|
||||
transport: "(Only the highest-level bonuses may be awarded)",
|
||||
quota: "(You'll receive a reward upon reaching each level)",
|
||||
@ -9,104 +14,30 @@ const DEFAULT_TASK_NOTES = {
|
||||
|
||||
const DEFAULTS = {
|
||||
view: "invite",
|
||||
inviteLink: "http://yourappbalbalanal",
|
||||
inviteCode: "123455678",
|
||||
invitees: "168",
|
||||
eligibleVoters: "01",
|
||||
rechargeTotal: "01",
|
||||
myRewards: "12354",
|
||||
inviteLink: "",
|
||||
inviteCode: "",
|
||||
invitees: "0",
|
||||
eligibleVoters: "0",
|
||||
rechargeTotal: "0",
|
||||
myRewards: "0",
|
||||
friendName: "Name",
|
||||
downloadUrl: "https://example.com/download",
|
||||
endAt: "",
|
||||
inviterBindReward: 2000,
|
||||
rechargeReward: 1000,
|
||||
giftReward: 1000,
|
||||
downloadUrl: "",
|
||||
inviterBindReward: 0,
|
||||
rechargeReward: 0,
|
||||
giftReward: 0,
|
||||
};
|
||||
|
||||
const FALLBACK_TASKS = {
|
||||
transport: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
transport: [],
|
||||
quota: [],
|
||||
};
|
||||
|
||||
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 = {
|
||||
view: normalizeView(query.get("view") || DEFAULTS.view),
|
||||
view: resolveInitialView(),
|
||||
apiBase: resolveAPIBase(),
|
||||
apiPrefix: resolveAPIPrefix(),
|
||||
authorization: resolveAuthorization(),
|
||||
landingCode: trimValue(query.get("code") || query.get("inviteCode")),
|
||||
landingCode: resolveLandingCode(),
|
||||
inviteLink: query.get("inviteLink") || DEFAULTS.inviteLink,
|
||||
inviteCode: query.get("inviteCode") || DEFAULTS.inviteCode,
|
||||
invitees: query.get("invitees") || DEFAULTS.invitees,
|
||||
@ -124,6 +55,9 @@ const state = {
|
||||
transport: cloneTasks(FALLBACK_TASKS.transport),
|
||||
quota: cloneTasks(FALLBACK_TASKS.quota),
|
||||
},
|
||||
rankItems: [],
|
||||
serverOffsetMs: 0,
|
||||
countdownEndAtMs: Date.now() + DEFAULT_RESET_PERIOD_MS,
|
||||
countdownTimer: null,
|
||||
pendingClaimRuleId: 0,
|
||||
};
|
||||
@ -174,15 +108,81 @@ 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("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 DEFAULT_INVITE_API_BASE;
|
||||
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) {
|
||||
@ -193,7 +193,20 @@ function resolveAPIBase() {
|
||||
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() {
|
||||
@ -375,6 +388,22 @@ function renderTasks() {
|
||||
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 isLoading = Number(item.ruleId) > 0 && state.pendingClaimRuleId === Number(item.ruleId);
|
||||
@ -412,7 +441,25 @@ function renderTasks() {
|
||||
}
|
||||
|
||||
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 = {
|
||||
1: "./assets/rank-top1.png",
|
||||
2: "./assets/rank-top2.png",
|
||||
@ -433,7 +480,7 @@ function renderRankList() {
|
||||
}
|
||||
</div>
|
||||
<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 class="rank-name">${escapeHtml(item.name)}</div>
|
||||
<div class="rank-score">${escapeHtml(item.score)}</div>
|
||||
@ -442,31 +489,13 @@ function renderRankList() {
|
||||
}).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) {
|
||||
return String(number).padStart(2, "0");
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
const endAt = parseEndAt();
|
||||
if (!endAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = Math.max(0, endAt - Date.now());
|
||||
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);
|
||||
@ -480,11 +509,6 @@ function updateCountdown() {
|
||||
}
|
||||
|
||||
function setupCountdown() {
|
||||
const endAt = parseEndAt();
|
||||
if (!endAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateCountdown();
|
||||
state.countdownTimer = window.setInterval(updateCountdown, 1000);
|
||||
}
|
||||
@ -519,14 +543,7 @@ function extractErrorMessage(payload) {
|
||||
|
||||
async function requestJSON(path, options = {}) {
|
||||
const { method = "GET", headers = {}, body, searchParams } = options;
|
||||
const url = new URL(path, state.apiBase);
|
||||
|
||||
Object.entries(searchParams || {}).forEach(([key, value]) => {
|
||||
const raw = trimValue(value);
|
||||
if (raw) {
|
||||
url.searchParams.set(key, raw);
|
||||
}
|
||||
});
|
||||
const url = buildAPIURL(path, searchParams);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method,
|
||||
@ -548,6 +565,33 @@ async function requestJSON(path, options = {}) {
|
||||
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) {
|
||||
if (!Array.isArray(list) || list.length === 0) {
|
||||
return cloneTasks(FALLBACK_TASKS[taskSet]);
|
||||
@ -560,7 +604,7 @@ function normalizeTaskList(list, taskSet) {
|
||||
const claimed = Boolean(item?.claimed);
|
||||
const desc =
|
||||
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`;
|
||||
|
||||
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) {
|
||||
["quota", "transport"].forEach((taskSet) => {
|
||||
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) {
|
||||
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;
|
||||
@ -612,8 +695,8 @@ function applyHomeData(body) {
|
||||
}
|
||||
|
||||
const rechargeReward =
|
||||
body.inviterTotalRechargeTasks?.[0]?.goldAmount ??
|
||||
body.inviteeRechargeRules?.[0]?.goldAmount;
|
||||
body.inviteeRechargeRules?.[0]?.goldAmount ??
|
||||
body.inviterTotalRechargeTasks?.[0]?.goldAmount;
|
||||
if (rechargeReward != null) {
|
||||
state.rechargeReward = rechargeReward;
|
||||
}
|
||||
@ -640,6 +723,7 @@ function applyLandingData(body) {
|
||||
const inviteCode = trimValue(body.inviteCode);
|
||||
if (inviteCode) {
|
||||
state.landingCode = inviteCode;
|
||||
state.inviteCode = inviteCode;
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!state.authorization) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = await requestJSON("/go/app/h5/invite-campaign/home", {
|
||||
const body = await requestJSON("/app/h5/invite-campaign/home", {
|
||||
headers: {
|
||||
Authorization: state.authorization,
|
||||
},
|
||||
@ -662,12 +790,29 @@ async function fetchHomeData() {
|
||||
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("/go/public/h5/invite-campaign/landing", {
|
||||
const body = await requestJSON("/public/h5/invite-campaign/landing", {
|
||||
searchParams: {
|
||||
code: state.landingCode,
|
||||
},
|
||||
@ -681,7 +826,10 @@ async function refreshPageData() {
|
||||
if (state.view === "gift") {
|
||||
await fetchLandingData();
|
||||
} else {
|
||||
await fetchHomeData();
|
||||
const hasHome = await fetchHomeData();
|
||||
if (hasHome) {
|
||||
await fetchRankData();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = trimValue(error?.message);
|
||||
@ -691,6 +839,7 @@ async function refreshPageData() {
|
||||
} finally {
|
||||
renderStats();
|
||||
renderTasks();
|
||||
renderRankList();
|
||||
}
|
||||
}
|
||||
|
||||
@ -708,7 +857,7 @@ async function claimTask(ruleId) {
|
||||
renderTasks();
|
||||
|
||||
try {
|
||||
const body = await requestJSON("/go/app/h5/invite-campaign/tasks/claim", {
|
||||
const body = await requestJSON("/app/h5/invite-campaign/tasks/claim", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: state.authorization,
|
||||
@ -720,12 +869,14 @@ async function claimTask(ruleId) {
|
||||
markTaskClaimed(ruleId);
|
||||
showToast(body?.alreadyClaimed ? "Already claimed" : "Claim succeeded");
|
||||
await fetchHomeData().catch(() => {});
|
||||
await fetchRankData().catch(() => {});
|
||||
} catch (error) {
|
||||
showToast(trimValue(error?.message) || "Claim failed");
|
||||
} finally {
|
||||
state.pendingClaimRuleId = 0;
|
||||
renderStats();
|
||||
renderTasks();
|
||||
renderRankList();
|
||||
}
|
||||
}
|
||||
|
||||
@ -739,7 +890,13 @@ function bindEvents() {
|
||||
|
||||
els.navButtons.forEach((button) => {
|
||||
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;
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
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 mimeTypes = {
|
||||
@ -18,14 +18,24 @@ const mimeTypes = {
|
||||
".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) {
|
||||
response.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.writeHead(status, withCORS({ "content-type": "text/plain; charset=utf-8" }));
|
||||
response.end(text);
|
||||
}
|
||||
|
||||
function serveStatic(request, response) {
|
||||
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));
|
||||
if (!filePath.startsWith(rootDir)) {
|
||||
sendText(response, 403, "Forbidden");
|
||||
@ -39,6 +49,7 @@ function serveStatic(request, response) {
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, {
|
||||
...corsHeaders,
|
||||
"content-type": mimeTypes[extname(filePath)] || "application/octet-stream",
|
||||
"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) {
|
||||
const incomingURL = new URL(request.url, "http://localhost");
|
||||
const targetURL = new URL(incomingURL.pathname.replace(/^\/api/, "") + incomingURL.search, apiTarget);
|
||||
@ -66,6 +97,7 @@ async function proxyAPI(request, response) {
|
||||
duplex: "half",
|
||||
});
|
||||
response.writeHead(upstream.status, {
|
||||
...corsHeaders,
|
||||
"content-type": upstream.headers.get("content-type") || "application/octet-stream",
|
||||
});
|
||||
if (upstream.body) {
|
||||
@ -75,7 +107,7 @@ async function proxyAPI(request, response) {
|
||||
}
|
||||
response.end();
|
||||
} 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({
|
||||
status: false,
|
||||
code: "proxy_error",
|
||||
@ -85,12 +117,19 @@ async function proxyAPI(request, response) {
|
||||
}
|
||||
|
||||
createServer((request, response) => {
|
||||
if (request.method === "OPTIONS") {
|
||||
response.writeHead(204, corsHeaders);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.url?.startsWith("/api/") || request.url === "/api") {
|
||||
proxyAPI(request, response);
|
||||
return;
|
||||
}
|
||||
serveStatic(request, response);
|
||||
}).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(`Proxy target: ${apiTarget}`);
|
||||
});
|
||||
|
||||
@ -358,7 +358,7 @@
|
||||
邀请链接
|
||||
<input id="inviteLinkOutput" readonly />
|
||||
</label>
|
||||
<div class="small" id="countdownText">月度倒计时未加载</div>
|
||||
<div class="small" id="countdownText">30天任务倒计时未加载</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -743,20 +743,21 @@
|
||||
|
||||
function startCountdown(home) {
|
||||
window.clearInterval(state.countdownTimer);
|
||||
const endAt = Number(home.periodEndAtMs || 0);
|
||||
const serverTime = Number(home.serverTimeMs || Date.now());
|
||||
const periodEndAt = Number(home.periodEndAtMs || (serverTime + 30 * 86400 * 1000));
|
||||
const offset = serverTime - Date.now();
|
||||
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 days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
els.countdownText.textContent =
|
||||
"当前月 " + (home.monthKey || "--") + ",距离重置:" +
|
||||
"30天任务周期距离重置:" +
|
||||
days + "天 " + pad(hours) + ":" + pad(minutes) + ":" + pad(secs) +
|
||||
",periodEndAtMs=" + endAt;
|
||||
",周期Key: " + escapeHtml(home.periodKey || home.monthKey || "--");
|
||||
};
|
||||
tick();
|
||||
state.countdownTimer = window.setInterval(tick, 1000);
|
||||
|
||||
@ -217,6 +217,7 @@
|
||||
}
|
||||
|
||||
.reward-amount {
|
||||
z-index: 999;
|
||||
position: absolute;
|
||||
top: 27px;
|
||||
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 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">
|
||||
<img data-icon="policy" alt="">
|
||||
<span data-i18n="bd_policy_link">BD policy</span>
|
||||
</span>
|
||||
<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">
|
||||
<span class="menu-label">
|
||||
@ -77,7 +77,7 @@
|
||||
</span>
|
||||
<img data-icon="arrow" class="flip-img" alt="">
|
||||
</button>
|
||||
|
||||
<!--
|
||||
<div class="divider"></div>
|
||||
|
||||
<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>
|
||||
<img data-icon="arrow" class="flip-img" alt="">
|
||||
</button>
|
||||
</button> -->
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@ -2,6 +2,7 @@ export const API_BASE_URL = "https://jvapi.haiyihy.com";
|
||||
|
||||
const text = {
|
||||
en: {
|
||||
language: "Language",
|
||||
bd_leader_center: "BD Leader Center",
|
||||
available_salaries: "Available salaries",
|
||||
bd: "BD",
|
||||
@ -185,7 +186,7 @@ function resolveLanguage() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const hash = window.location.hash.includes("?") ? window.location.hash.split("?")[1] : "";
|
||||
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("tr")) return "tr";
|
||||
if (value.startsWith("bn")) return "bn";
|
||||
|
||||
@ -2,34 +2,17 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<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 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">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Language</title>
|
||||
<link rel="stylesheet" href="../bd-static/shared.css">
|
||||
<link rel="stylesheet" href="./language.css">
|
||||
</head>
|
||||
<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>
|
||||
</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