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:
-
+
Minutes
0/120
-
+
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.name)}
${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 + + + +
+ + +
+
+ Inviter avatar +
+ +

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
+
+ Personal gift +
+
+ + + +
+
+

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

+
+ +
+
+
+ App home preview +
+
+ Gift preview +
+
+ App reward preview +
+
+ App activity preview +
+ + + + +
+
+ + +
+
+ +
+ + + + 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("/"); +}