diff --git a/h5/agency-center/index.html b/h5/agency-center/index.html
index 658d23f..dda5b99 100644
--- a/h5/agency-center/index.html
+++ b/h5/agency-center/index.html
@@ -47,19 +47,19 @@
My task as a host today is:
-
+
-
+
diff --git a/h5/app-invite/app.js b/h5/app-invite/app.js
index 02397f2..5805531 100644
--- a/h5/app-invite/app.js
+++ b/h5/app-invite/app.js
@@ -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 = `
+
+
+
No tasks yet
+
Task data will appear after the activity config is loaded.
+
+
+
+ `;
+ 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 = `
+
+ --
+
+

+
+ No ranking data yet
+ 0 people
+
+ `;
+ 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() {
}
-

+
${escapeHtml(item.name)}
${escapeHtml(item.score)}
@@ -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);
});
}
diff --git a/h5/app-invite/assets/btn-bg.png b/h5/app-invite/assets/btn-bg.png
new file mode 100644
index 0000000..3bcd7c1
Binary files /dev/null and b/h5/app-invite/assets/btn-bg.png differ
diff --git a/h5/app-invite/assets/newuser.png b/h5/app-invite/assets/newuser.png
new file mode 100644
index 0000000..0b46e52
Binary files /dev/null and b/h5/app-invite/assets/newuser.png differ
diff --git a/h5/app-invite/assets/p1.png b/h5/app-invite/assets/p1.png
new file mode 100644
index 0000000..ee89a41
Binary files /dev/null and b/h5/app-invite/assets/p1.png differ
diff --git a/h5/app-invite/assets/p2.png b/h5/app-invite/assets/p2.png
new file mode 100644
index 0000000..b753fac
Binary files /dev/null and b/h5/app-invite/assets/p2.png differ
diff --git a/h5/app-invite/assets/p3.png b/h5/app-invite/assets/p3.png
new file mode 100644
index 0000000..b2299ea
Binary files /dev/null and b/h5/app-invite/assets/p3.png differ
diff --git a/h5/app-invite/assets/p4.png b/h5/app-invite/assets/p4.png
new file mode 100644
index 0000000..b753fac
Binary files /dev/null and b/h5/app-invite/assets/p4.png differ
diff --git a/h5/app-invite/dev-test-server.mjs b/h5/app-invite/dev-test-server.mjs
index af776a5..774b664 100644
--- a/h5/app-invite/dev-test-server.mjs
+++ b/h5/app-invite/dev-test-server.mjs
@@ -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}`);
});
diff --git a/h5/app-invite/dev-test.html b/h5/app-invite/dev-test.html
index d78ba8b..18ac112 100644
--- a/h5/app-invite/dev-test.html
+++ b/h5/app-invite/dev-test.html
@@ -358,7 +358,7 @@
邀请链接
-
月度倒计时未加载
+
30天任务倒计时未加载
@@ -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);
diff --git a/h5/app-invite/index.html b/h5/app-invite/index.html
index c06f98d..b101ea8 100644
--- a/h5/app-invite/index.html
+++ b/h5/app-invite/index.html
@@ -217,6 +217,7 @@
}
.reward-amount {
+ z-index: 999;
position: absolute;
top: 27px;
right: 6px;
diff --git a/h5/app-invite/landing.html b/h5/app-invite/landing.html
new file mode 100644
index 0000000..69c2a45
--- /dev/null
+++ b/h5/app-invite/landing.html
@@ -0,0 +1,400 @@
+
+
+
+
+
+ Invite Gift
+
+
+
+
+
+

+
+
+
+
+

+
+
+ Your friend [Name]
has invited you to join
+ Join him to receive an exclusive welcome gift
+
+
+
Your Personal Gift
+
(This is a special gift from your friend)
+
+
+1000
+
+

+
+
+
+
+

+
+
+
(Available only through this invitation link! Don't miss out!)
+
+
+
+
+
+

+
+
+

+
+
+

+
+
+

+
+
+

+
+
+

+
+
+

+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/h5/app-invite/landing.js b/h5/app-invite/landing.js
new file mode 100644
index 0000000..22cbb50
--- /dev/null
+++ b/h5/app-invite/landing.js
@@ -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)}]
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();
diff --git a/h5/bd-center/index.html b/h5/bd-center/index.html
index d5138a0..c17605d 100644
--- a/h5/bd-center/index.html
+++ b/h5/bd-center/index.html
@@ -40,15 +40,15 @@
diff --git a/h5/bd-static/shared.js b/h5/bd-static/shared.js
index 2e1c3fc..898566e 100644
--- a/h5/bd-static/shared.js
+++ b/h5/bd-static/shared.js
@@ -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";
diff --git a/h5/language/index.html b/h5/language/index.html
index 9bced21..4f7703d 100644
--- a/h5/language/index.html
+++ b/h5/language/index.html
@@ -2,34 +2,17 @@
-
-
-
-
-
-
-
-
- Yumi H5
-
-
-
-
-
+
+ Language
+
+
-
+
+
+
+
diff --git a/h5/language/language.css b/h5/language/language.css
new file mode 100644
index 0000000..60dbbd5
--- /dev/null
+++ b/h5/language/language.css
@@ -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;
+}
diff --git a/h5/language/language.js b/h5/language/language.js
new file mode 100644
index 0000000..8895b22
--- /dev/null
+++ b/h5/language/language.js
@@ -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) => `
+
+ `).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("/");
+}