添加邀请

This commit is contained in:
170-carry 2026-04-27 19:11:53 +08:00
parent cbc72e8489
commit f42551294f
18 changed files with 1178 additions and 182 deletions

View File

@ -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>

View File

@ -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);
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

View File

@ -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}`);
});

View File

@ -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);

View File

@ -217,6 +217,7 @@
}
.reward-amount {
z-index: 999;
position: absolute;
top: 27px;
right: 6px;

400
h5/app-invite/landing.html Normal file
View 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
View 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 = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
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();

View File

@ -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>

View File

@ -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";

View File

@ -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
View 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
View 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("/");
}