This commit is contained in:
hy 2026-04-24 12:42:12 +08:00
parent eafe4a947e
commit bf861973f4
71 changed files with 8531 additions and 1 deletions

794
h5/app-invite/app.js Normal file
View File

@ -0,0 +1,794 @@
const query = new URLSearchParams(window.location.search);
const DEFAULT_INVITE_API_BASE = "https://jvapi.haiyihy.com/";
const DEFAULT_AVATAR = "./assets/avatar-sample.png";
const DEFAULT_TASK_NOTES = {
transport: "(Only the highest-level bonuses may be awarded)",
quota: "(You'll receive a reward upon reaching each level)",
};
const DEFAULTS = {
view: "invite",
inviteLink: "http://yourappbalbalanal",
inviteCode: "123455678",
invitees: "168",
eligibleVoters: "01",
rechargeTotal: "01",
myRewards: "12354",
friendName: "Name",
downloadUrl: "https://example.com/download",
endAt: "",
inviterBindReward: 2000,
rechargeReward: 1000,
giftReward: 1000,
};
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,
},
],
};
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),
apiBase: resolveAPIBase(),
authorization: resolveAuthorization(),
landingCode: trimValue(query.get("code") || query.get("inviteCode")),
inviteLink: query.get("inviteLink") || DEFAULTS.inviteLink,
inviteCode: query.get("inviteCode") || DEFAULTS.inviteCode,
invitees: query.get("invitees") || DEFAULTS.invitees,
eligibleVoters: query.get("eligible") || DEFAULTS.eligibleVoters,
rechargeTotal: query.get("recharge") || DEFAULTS.rechargeTotal,
myRewards: query.get("rewards") || DEFAULTS.myRewards,
friendName: query.get("friendName") || DEFAULTS.friendName,
inviterAvatar: DEFAULT_AVATAR,
downloadUrl: query.get("downloadUrl") || DEFAULTS.downloadUrl,
inviterBindReward: query.get("inviterReward") || DEFAULTS.inviterBindReward,
rechargeReward: query.get("rechargeReward") || DEFAULTS.rechargeReward,
giftReward: query.get("giftReward") || DEFAULTS.giftReward,
taskSet: normalizeTaskSet(query.get("taskSet") || "quota"),
tasks: {
transport: cloneTasks(FALLBACK_TASKS.transport),
quota: cloneTasks(FALLBACK_TASKS.quota),
},
countdownTimer: null,
pendingClaimRuleId: 0,
};
const els = {
views: [...document.querySelectorAll("[data-view]")],
inviteLinkText: document.getElementById("inviteLinkText"),
inviteCodeText: document.getElementById("inviteCodeText"),
statInvitees: document.getElementById("statInvitees"),
statEligible: document.getElementById("statEligible"),
statRecharge: document.getElementById("statRecharge"),
statRewards: document.getElementById("statRewards"),
taskList: document.getElementById("taskList"),
taskNote: document.getElementById("taskNote"),
taskTabs: [...document.querySelectorAll("[data-task-set]")],
navButtons: [...document.querySelectorAll("[data-nav-view]")],
copyButtons: [...document.querySelectorAll("[data-copy-target]")],
rankList: document.getElementById("rankList"),
giftTitle: document.getElementById("giftTitle"),
giftAvatarImage: document.getElementById("giftAvatarImage"),
downloadButton: document.getElementById("downloadButton"),
inviteRewardAmounts: [
...document.querySelectorAll('.reward-panel .reward-amount'),
],
giftRewardAmount: document.querySelector('.gift-panel .reward-amount'),
toast: document.getElementById("pageToast"),
countdown: {
days: document.querySelector('[data-countdown="days"]'),
hours: document.querySelector('[data-countdown="hours"]'),
minutes: document.querySelector('[data-countdown="minutes"]'),
seconds: document.querySelector('[data-countdown="seconds"]'),
},
};
function normalizeView(value) {
if (value === "rank" || value === "gift") {
return value;
}
return "invite";
}
function normalizeTaskSet(value) {
return value === "transport" ? "transport" : "quota";
}
function trimValue(value) {
return value == null ? "" : String(value).trim();
}
function normalizeBaseURL(base) {
const value = trimValue(base);
if (!value) {
return DEFAULT_INVITE_API_BASE;
}
return value.endsWith("/") ? value : `${value}/`;
}
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(DEFAULT_INVITE_API_BASE);
}
function resolveAuthorization() {
const candidates = [
query.get("authorization"),
query.get("Authorization"),
query.get("token"),
query.get("auth"),
typeof window.__APP_INVITE_AUTHORIZATION__ === "string"
? window.__APP_INVITE_AUTHORIZATION__
: "",
typeof window.__APP_INVITE_TOKEN__ === "string" ? window.__APP_INVITE_TOKEN__ : "",
];
const value = candidates.find((item) => trimValue(item));
if (!value) {
return "";
}
const normalized = trimValue(value);
if (/^bearer\s+/i.test(normalized)) {
return normalized;
}
return `Bearer ${normalized}`;
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (match) => {
const entityMap = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
return entityMap[match] || match;
});
}
function cloneTasks(list) {
return list.map((item) => ({ ...item }));
}
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 loadImage(image) {
if (!image || image.getAttribute("src")) {
return;
}
const src = image.getAttribute("data-src");
if (src) {
image.setAttribute("src", src);
}
}
function setImageSource(image, src, fallbackSrc = "") {
if (!(image instanceof HTMLImageElement)) {
return;
}
const nextSrc = trimValue(src) || fallbackSrc;
if (!nextSrc) {
return;
}
image.setAttribute("src", nextSrc);
image.setAttribute("data-src", nextSrc);
}
function hydrateViewAssets(view) {
const targets = {
invite: [
document.getElementById("inviteHeroImage"),
...document.querySelectorAll('.view[data-view="invite"] .lazy-image'),
],
rank: [
...document.querySelectorAll('.view[data-view="rank"] [data-badge-src]'),
...document.querySelectorAll('.view[data-view="rank"] [data-avatar-src]'),
],
gift: [
document.getElementById("giftBgImage"),
document.getElementById("giftAvatarImage"),
document.getElementById("phoneHomeImage"),
document.getElementById("phoneGiftImage"),
...document.querySelectorAll('.view[data-view="gift"] .lazy-image'),
],
};
(targets[view] || []).forEach((item) => {
if (item instanceof HTMLImageElement) {
loadImage(item);
}
});
}
function setView(view) {
state.view = normalizeView(view);
els.views.forEach((section) => {
section.classList.toggle("active", section.getAttribute("data-view") === state.view);
});
hydrateViewAssets(state.view);
const url = new URL(window.location.href);
if (state.view === DEFAULTS.view) {
url.searchParams.delete("view");
} else {
url.searchParams.set("view", state.view);
}
window.history.replaceState({}, "", url.toString());
}
function formatMetric(value) {
const raw = trimValue(value);
if (!raw) {
return "--";
}
if (/^-?\d+(\.\d+)?$/.test(raw)) {
return new Intl.NumberFormat("en-US").format(Number(raw));
}
return raw;
}
function formatSignedReward(value) {
const raw = trimValue(value);
if (!raw) {
return "+0";
}
if (raw.startsWith("+")) {
return raw;
}
return `+${formatMetric(raw)}`;
}
function renderStats() {
els.inviteLinkText.textContent = trimValue(state.inviteLink) || "--";
els.inviteCodeText.textContent = trimValue(state.inviteCode) || "--";
els.statInvitees.textContent = formatMetric(state.invitees);
els.statEligible.textContent = formatMetric(state.eligibleVoters);
els.statRecharge.textContent = formatMetric(state.rechargeTotal);
els.statRewards.textContent = formatMetric(state.myRewards);
els.giftTitle.innerHTML = `Your friend [${escapeHtml(state.friendName)}]<br />has invited you to join`;
setImageSource(els.giftAvatarImage, state.inviterAvatar, DEFAULT_AVATAR);
if (els.inviteRewardAmounts[0]) {
els.inviteRewardAmounts[0].textContent = formatSignedReward(state.inviterBindReward);
}
if (els.inviteRewardAmounts[1]) {
els.inviteRewardAmounts[1].textContent = formatSignedReward(state.rechargeReward);
}
if (els.giftRewardAmount) {
els.giftRewardAmount.textContent = formatSignedReward(state.giftReward);
}
}
function renderTasks() {
const current = state.tasks[state.taskSet] || cloneTasks(FALLBACK_TASKS[state.taskSet]);
els.taskTabs.forEach((tab) => {
const active = tab.getAttribute("data-task-set") === state.taskSet;
tab.classList.toggle("active", active);
tab.classList.toggle("passive", !active);
});
els.taskList.innerHTML = current
.map((item) => {
const isLoading = Number(item.ruleId) > 0 && state.pendingClaimRuleId === Number(item.ruleId);
const disabled = item.disabled || isLoading || Number(item.ruleId) <= 0;
const buttonLabel = isLoading ? "Loading..." : item.buttonLabel;
let buttonStateClass = " is-pending";
if (isLoading) {
buttonStateClass = " is-loading";
} else if (item.claimed) {
buttonStateClass = " is-claimed";
} else if (item.achieved && !item.claimed) {
buttonStateClass = " is-claimable";
}
return `
<article class="task-card">
<div>
<p class="task-title">${escapeHtml(item.title)}</p>
<p class="task-desc">${escapeHtml(item.desc)}</p>
</div>
<button
type="button"
class="claim-button${buttonStateClass}${disabled ? " disabled" : ""}"
data-rule-id="${Number(item.ruleId) || 0}"
${disabled ? "disabled" : ""}
>
${escapeHtml(buttonLabel)}
</button>
</article>
`;
})
.join("");
els.taskNote.textContent = DEFAULT_TASK_NOTES[state.taskSet] || "";
}
function renderRankList() {
els.rankList.innerHTML = DEFAULT_RANKS.map((item) => {
const badgeMap = {
1: "./assets/rank-top1.png",
2: "./assets/rank-top2.png",
3: "./assets/rank-top3.png",
};
const badge = badgeMap[item.rank];
return `
<article
class="rank-row"
style="background-image:url('./assets/rank-row.svg')"
>
<div class="rank-badge">
${
badge
? `<img src="${badge}" alt="Rank ${item.rank}" />`
: escapeHtml(String(item.rank))
}
</div>
<div class="avatar">
<img src="./assets/avatar-sample.png" alt="${escapeHtml(item.name)}" />
</div>
<div class="rank-name">${escapeHtml(item.name)}</div>
<div class="rank-score">${escapeHtml(item.score)}</div>
</article>
`;
}).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 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.countdown.days.textContent = pad(days);
els.countdown.hours.textContent = pad(hours);
els.countdown.minutes.textContent = pad(minutes);
els.countdown.seconds.textContent = pad(secs);
}
function setupCountdown() {
const endAt = parseEndAt();
if (!endAt) {
return;
}
updateCountdown();
state.countdownTimer = window.setInterval(updateCountdown, 1000);
}
async function copyText(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const input = document.createElement("textarea");
input.value = text;
input.setAttribute("readonly", "");
input.style.position = "absolute";
input.style.left = "-9999px";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
}
function extractErrorMessage(payload) {
const candidates = [
payload?.errorMsg,
payload?.message,
payload?.errorCodeName,
payload?.code,
];
return candidates.find((item) => trimValue(item)) || "";
}
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 response = await fetch(url.toString(), {
method,
headers: {
Accept: "application/json",
...headers,
},
body,
});
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("application/json") ? await response.json() : null;
if (!response.ok || payload?.status === false) {
const message = extractErrorMessage(payload) || `Request failed (${response.status})`;
throw new Error(message);
}
return payload?.body || payload?.data || payload || null;
}
function normalizeTaskList(list, taskSet) {
if (!Array.isArray(list) || list.length === 0) {
return cloneTasks(FALLBACK_TASKS[taskSet]);
}
return list.map((item, index) => {
const threshold = Number(item?.threshold || 0);
const goldAmount = Number(item?.goldAmount || 0);
const achieved = Boolean(item?.achieved);
const claimed = Boolean(item?.claimed);
const desc =
taskSet === "quota"
? `Invite ${formatMetric(threshold)} valid friends to claim ${formatMetric(goldAmount)} coins`
: `Reach ${formatMetric(threshold)} recharge coins to claim ${formatMetric(goldAmount)} coins`;
return {
title: `Milestone ${index + 1}`,
desc,
buttonLabel: claimed ? "Claimed" : achieved ? "Claim" : "Incomplete",
disabled: claimed || !achieved,
claimed,
achieved,
ruleId: Number(item?.ruleId) || 0,
};
});
}
function markTaskClaimed(ruleId) {
["quota", "transport"].forEach((taskSet) => {
state.tasks[taskSet] = (state.tasks[taskSet] || []).map((item) => {
if (Number(item.ruleId) !== Number(ruleId)) {
return item;
}
return {
...item,
claimed: true,
disabled: true,
buttonLabel: "Claimed",
};
});
});
}
function applyHomeData(body) {
if (!body || typeof body !== "object") {
return;
}
const stats = body.stats || {};
state.inviteLink = trimValue(body.inviteLink) || state.inviteLink;
state.inviteCode = trimValue(body.inviteCode) || state.inviteCode;
state.downloadUrl = trimValue(body.downloadUrl) || state.downloadUrl;
state.invitees = stats.totalInvitedUsers ?? state.invitees;
state.eligibleVoters = stats.totalValidUsers ?? state.eligibleVoters;
state.rechargeTotal = stats.totalRechargeCoins ?? state.rechargeTotal;
state.myRewards = stats.rewardGoldCoins ?? state.myRewards;
const inviterBindReward = body.inviterBindReward?.goldAmount;
if (inviterBindReward != null) {
state.inviterBindReward = inviterBindReward;
}
const rechargeReward =
body.inviterTotalRechargeTasks?.[0]?.goldAmount ??
body.inviteeRechargeRules?.[0]?.goldAmount;
if (rechargeReward != null) {
state.rechargeReward = rechargeReward;
}
state.tasks.quota = normalizeTaskList(body.inviterValidCountTasks, "quota");
state.tasks.transport = normalizeTaskList(body.inviterTotalRechargeTasks, "transport");
const shareTitle = trimValue(body.shareTitle);
if (shareTitle) {
document.title = shareTitle;
}
}
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.landingCode = inviteCode;
}
const shareTitle = trimValue(body.shareTitle);
if (shareTitle) {
document.title = shareTitle;
}
}
async function fetchHomeData() {
if (!state.authorization) {
return false;
}
const body = await requestJSON("/go/app/h5/invite-campaign/home", {
headers: {
Authorization: state.authorization,
},
});
applyHomeData(body);
return true;
}
async function fetchLandingData() {
if (!state.landingCode) {
return false;
}
const body = await requestJSON("/go/public/h5/invite-campaign/landing", {
searchParams: {
code: state.landingCode,
},
});
applyLandingData(body);
return true;
}
async function refreshPageData() {
try {
if (state.view === "gift") {
await fetchLandingData();
} else {
await fetchHomeData();
}
} catch (error) {
const message = trimValue(error?.message);
if (message) {
showToast(message);
}
} finally {
renderStats();
renderTasks();
}
}
async function claimTask(ruleId) {
if (!ruleId) {
return;
}
if (!state.authorization) {
showToast("Token is missing");
return;
}
state.pendingClaimRuleId = ruleId;
renderTasks();
try {
const body = await requestJSON("/go/app/h5/invite-campaign/tasks/claim", {
method: "POST",
headers: {
Authorization: state.authorization,
"Content-Type": "application/json",
},
body: JSON.stringify({ ruleId }),
});
markTaskClaimed(ruleId);
showToast(body?.alreadyClaimed ? "Already claimed" : "Claim succeeded");
await fetchHomeData().catch(() => {});
} catch (error) {
showToast(trimValue(error?.message) || "Claim failed");
} finally {
state.pendingClaimRuleId = 0;
renderStats();
renderTasks();
}
}
function bindEvents() {
els.taskTabs.forEach((button) => {
button.addEventListener("click", () => {
state.taskSet = normalizeTaskSet(button.getAttribute("data-task-set"));
renderTasks();
});
});
els.navButtons.forEach((button) => {
button.addEventListener("click", () => {
setView(button.getAttribute("data-nav-view") || "invite");
});
});
els.copyButtons.forEach((button) => {
button.addEventListener("click", async () => {
const target = button.getAttribute("data-copy-target");
const value = target === "invite-code" ? state.inviteCode : state.inviteLink;
try {
await copyText(trimValue(value));
showToast(target === "invite-code" ? "Invitation code copied" : "Invitation link copied");
} catch (error) {
showToast("Copy failed");
}
});
});
els.taskList.addEventListener("click", (event) => {
const button = event.target.closest("[data-rule-id]");
if (!(button instanceof HTMLButtonElement)) {
return;
}
const ruleId = Number(button.getAttribute("data-rule-id") || 0);
if (!ruleId || button.disabled) {
return;
}
claimTask(ruleId);
});
els.downloadButton.addEventListener("click", () => {
if (!state.downloadUrl) {
showToast("Download link is not configured");
return;
}
window.location.href = state.downloadUrl;
});
}
async function init() {
renderStats();
renderTasks();
renderRankList();
bindEvents();
setView(state.view);
setupCountdown();
await refreshPageData();
}
init();

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 204.5 52.25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector 1" d="M10.501 52.25H194.515L194.425 48.5L204.5 42L203.75 9.25L193.432 7L193.264 0L10.501 0.75V5.75L0 9.25V42.25L10.501 47.75V52.25Z" fill="var(--fill-0, #0B3008)"/>
</svg>

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

@ -0,0 +1,21 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 760.5 125.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Vector 1" filter="url(#filter0_i_0_6)">
<path d="M738.498 16V0H22.0683L18.0013 15.1769L0 20V103.873L21.0015 109.5V125.5H740.998V105H760.5V16H738.498Z" fill="url(#paint0_linear_0_6)"/>
</g>
<defs>
<filter id="filter0_i_0_6" x="0" y="0" width="760.5" height="125.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="6.9"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.84 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_0_6"/>
</filter>
<linearGradient id="paint0_linear_0_6" x1="380.499" y1="104.5" x2="380.499" y2="0.500003" gradientUnits="userSpaceOnUse">
<stop stop-color="#206C1A"/>
<stop offset="1" stop-color="#3CA733"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

@ -0,0 +1,21 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 1002.21 165.552" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Vector 1" filter="url(#filter0_i_0_7)">
<path d="M973.215 21.1063V0H29.0822L23.7227 20.0205L0 26.3828V137.023L27.6765 144.446V165.552H976.509V138.51H1002.21V21.1063H973.215Z" fill="url(#paint0_linear_0_7)"/>
</g>
<defs>
<filter id="filter0_i_0_7" x="0" y="0" width="1002.21" height="165.552" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="6.9"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.84 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_0_7"/>
</filter>
<linearGradient id="paint0_linear_0_7" x1="501.434" y1="137.85" x2="501.434" y2="0.659574" gradientUnits="userSpaceOnUse">
<stop stop-color="#206C1A"/>
<stop offset="1" stop-color="#3CA733"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 880 149" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Rectangle 52" d="M0 19C0 8.50659 8.50659 0 19 0H861C871.493 0 880 8.50659 880 19V130C880 140.493 871.493 149 861 149H19C8.50657 149 0 140.493 0 130V19Z" fill="var(--fill-0, #042207)"/>
</svg>

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 409 104.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector 1" d="M21.002 104.5H389.029L388.85 97L409 84L407.5 18.5L386.864 14L386.529 0L21.002 1.5V11.5L0 18.5V84.5L21.002 95.5V104.5Z" fill="var(--fill-0, #0B3008)"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 409 104.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector 1" d="M21.002 104.5H389.029L388.85 97L409 84L407.5 18.5L386.864 14L386.529 0L21.002 1.5V11.5L0 18.5V84.5L21.002 95.5V104.5Z" fill="var(--fill-0, #0B3008)"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 880 169" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Rectangle 52" d="M0 19C0 8.50659 8.50659 0 19 0H861C871.493 0 880 8.50659 880 19V150C880 160.493 871.493 169 861 169H19C8.50657 169 0 160.493 0 150V19Z" fill="var(--fill-0, #042207)"/>
</svg>

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 975 143" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect id="Rectangle 61" width="975" height="143" fill="var(--fill-0, #D9D9D9)"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -0,0 +1,96 @@
import { createServer } from "node:http";
import { createReadStream, statSync } from "node:fs";
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 apiTarget = String(process.env.INVITE_API_TARGET || "http://127.0.0.1:2900").replace(/\/$/, "");
const mimeTypes = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
};
function sendText(response, status, text) {
response.writeHead(status, { "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 filePath = normalize(join(rootDir, pathname));
if (!filePath.startsWith(rootDir)) {
sendText(response, 403, "Forbidden");
return;
}
try {
const stat = statSync(filePath);
if (!stat.isFile()) {
sendText(response, 404, "Not found");
return;
}
response.writeHead(200, {
"content-type": mimeTypes[extname(filePath)] || "application/octet-stream",
"content-length": stat.size,
});
createReadStream(filePath).pipe(response);
} catch {
sendText(response, 404, "Not found");
}
}
async function proxyAPI(request, response) {
const incomingURL = new URL(request.url, "http://localhost");
const targetURL = new URL(incomingURL.pathname.replace(/^\/api/, "") + incomingURL.search, apiTarget);
const headers = new Headers();
for (const [key, value] of Object.entries(request.headers)) {
if (value !== undefined && key.toLowerCase() !== "host") {
headers.set(key, Array.isArray(value) ? value.join(",") : value);
}
}
try {
const upstream = await fetch(targetURL, {
method: request.method,
headers,
body: request.method === "GET" || request.method === "HEAD" ? undefined : request,
duplex: "half",
});
response.writeHead(upstream.status, {
"content-type": upstream.headers.get("content-type") || "application/octet-stream",
});
if (upstream.body) {
for await (const chunk of upstream.body) {
response.write(chunk);
}
}
response.end();
} catch (error) {
response.writeHead(502, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify({
status: false,
code: "proxy_error",
message: error instanceof Error ? error.message : "proxy error",
}));
}
}
createServer((request, response) => {
if (request.url?.startsWith("/api/") || request.url === "/api") {
proxyAPI(request, response);
return;
}
serveStatic(request, response);
}).listen(port, "127.0.0.1", () => {
console.log(`Invite dev test page: http://127.0.0.1:${port}/dev-test.html`);
console.log(`Proxy target: ${apiTarget}`);
});

799
h5/app-invite/dev-test.html Normal file
View File

@ -0,0 +1,799 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>邀请活动本地接口测试</title>
<style>
:root {
color-scheme: light;
--bg: #f5f7f4;
--panel: #ffffff;
--line: #d8dfd2;
--text: #1f2b1d;
--muted: #64705f;
--brand: #176d36;
--brand-soft: #e7f4ea;
--danger: #a62b21;
--danger-soft: #fff0ee;
--code: #102414;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family:
ui-sans-serif, -apple-system, BlinkMacSystemFont, "PingFang SC",
"Helvetica Neue", Arial, sans-serif;
}
button,
input,
textarea {
font: inherit;
}
button {
border: 0;
border-radius: 8px;
padding: 10px 14px;
background: var(--brand);
color: #fff;
cursor: pointer;
font-weight: 700;
}
button.secondary {
background: var(--brand-soft);
color: var(--brand);
}
button.danger {
background: var(--danger-soft);
color: var(--danger);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 12px;
color: var(--text);
background: #fff;
}
textarea {
min-height: 76px;
resize: vertical;
}
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 13px;
}
pre {
margin: 0;
padding: 14px;
border-radius: 8px;
overflow: auto;
background: var(--code);
color: #dff5dd;
font-size: 12px;
line-height: 1.45;
}
.page {
max-width: 1180px;
margin: 0 auto;
padding: 24px;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.title {
margin: 0;
font-size: 26px;
line-height: 1.15;
}
.subtitle {
margin: 8px 0 0;
color: var(--muted);
}
.status {
min-width: 210px;
padding: 10px 12px;
border-radius: 8px;
background: var(--panel);
border: 1px solid var(--line);
color: var(--muted);
font-size: 13px;
}
.layout {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
padding: 16px;
}
.panel h2 {
margin: 0 0 12px;
font-size: 17px;
}
.grid {
display: grid;
gap: 12px;
}
.row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.metric {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #fbfcfa;
}
.metric strong {
display: block;
margin-top: 4px;
font-size: 20px;
}
.task-list,
.rank-list {
display: grid;
gap: 10px;
}
.task,
.rank {
display: grid;
gap: 8px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #fbfcfa;
}
.task-head,
.rank {
grid-template-columns: 1fr auto;
align-items: center;
}
.task-head {
display: grid;
gap: 8px;
}
.task-meta,
.small {
color: var(--muted);
font-size: 12px;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 999px;
object-fit: cover;
background: var(--line);
}
.rank-user {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.rank-name {
font-weight: 700;
}
.logs {
display: grid;
gap: 10px;
}
.log-entry {
display: grid;
gap: 6px;
}
.log-title {
display: flex;
justify-content: space-between;
gap: 10px;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 900px) {
.layout,
.metric-grid {
grid-template-columns: 1fr;
}
.topbar {
display: grid;
}
}
</style>
</head>
<body>
<main class="page">
<div class="topbar">
<div>
<h1 class="title">邀请活动本地接口测试</h1>
<p class="subtitle">
默认通过本页同源的 <code>/api</code> 代理到本地 Go 服务,避免浏览器 CORS 限制。
</p>
</div>
<div class="status" id="statusText">等待操作</div>
</div>
<div class="layout">
<aside class="panel">
<h2>测试参数</h2>
<div class="grid">
<label>
API Base
<input id="apiBaseInput" value="/api" />
</label>
<label>
邀请人 token
<textarea id="inviterTokenInput" placeholder="用于 /app/h5/invite-campaign/home、rank、claim"></textarea>
</label>
<label>
被邀请人 token
<textarea id="inviteeTokenInput" placeholder="用于 bind-code"></textarea>
</label>
<div class="row">
<label>
邀请码
<input id="inviteCodeInput" placeholder="查首页后自动填入" />
</label>
<label>
排行 limit
<input id="rankLimitInput" type="number" value="50" />
</label>
</div>
<div class="row">
<label>
被邀请人 userId
<input id="inviteeUserIdInput" type="number" placeholder="充值回调用" />
</label>
<label>
sysOrigin
<input id="sysOriginInput" placeholder="查首页后自动填入" />
</label>
</div>
<div class="row">
<label>
充值金币
<input id="rechargeCoinsInput" type="number" value="100000" />
</label>
<label>
订单号
<input id="orderIdInput" />
</label>
</div>
<div class="actions">
<button type="button" id="saveButton" class="secondary">保存参数</button>
<button type="button" id="resetButton" class="danger">清空日志</button>
</div>
</div>
</aside>
<section class="grid">
<div class="panel">
<h2>流程操作</h2>
<div class="actions">
<button type="button" data-action="health">1. 健康检查</button>
<button type="button" data-action="home">2. 邀请人首页</button>
<button type="button" data-action="rank">3. 排行榜</button>
<button type="button" data-action="landing">4. 落地页</button>
<button type="button" data-action="bind">5. 被邀请人绑码</button>
<button type="button" data-action="recharge">6. 模拟充值回调</button>
<button type="button" data-action="refresh">7. 刷新首页+榜单</button>
</div>
<p class="small">
推荐顺序:填邀请人 token -> 查首页 -> 填被邀请人 token 和 userId -> 绑码 -> 充值回调 -> 刷新 -> 点任务 Claim。
</p>
</div>
<div class="panel">
<h2>首页结果</h2>
<div class="metric-grid" id="metrics"></div>
<div class="grid" style="margin-top: 14px">
<label>
邀请链接
<input id="inviteLinkOutput" readonly />
</label>
<div class="small" id="countdownText">月度倒计时未加载</div>
</div>
</div>
<div class="panel">
<h2>任务领取</h2>
<div class="task-list" id="taskList"></div>
</div>
<div class="panel">
<h2>排行榜</h2>
<div class="rank-list" id="rankList"></div>
</div>
<div class="panel">
<h2>接口日志</h2>
<div class="logs" id="logs"></div>
</div>
</section>
</div>
</main>
<script>
const storageKey = "appInviteDevTest";
const state = {
home: null,
rank: null,
landing: null,
countdownTimer: 0,
};
const els = {
statusText: document.getElementById("statusText"),
apiBaseInput: document.getElementById("apiBaseInput"),
inviterTokenInput: document.getElementById("inviterTokenInput"),
inviteeTokenInput: document.getElementById("inviteeTokenInput"),
inviteCodeInput: document.getElementById("inviteCodeInput"),
rankLimitInput: document.getElementById("rankLimitInput"),
inviteeUserIdInput: document.getElementById("inviteeUserIdInput"),
sysOriginInput: document.getElementById("sysOriginInput"),
rechargeCoinsInput: document.getElementById("rechargeCoinsInput"),
orderIdInput: document.getElementById("orderIdInput"),
inviteLinkOutput: document.getElementById("inviteLinkOutput"),
countdownText: document.getElementById("countdownText"),
metrics: document.getElementById("metrics"),
taskList: document.getElementById("taskList"),
rankList: document.getElementById("rankList"),
logs: document.getElementById("logs"),
};
function loadSavedParams() {
const saved = JSON.parse(localStorage.getItem(storageKey) || "{}");
Object.entries(saved).forEach(([key, value]) => {
if (els[key]) {
els[key].value = value;
}
});
if (!els.orderIdInput.value) {
els.orderIdInput.value = nextOrderId();
}
}
function saveParams() {
const payload = {};
[
"apiBaseInput",
"inviterTokenInput",
"inviteeTokenInput",
"inviteCodeInput",
"rankLimitInput",
"inviteeUserIdInput",
"sysOriginInput",
"rechargeCoinsInput",
].forEach((key) => {
payload[key] = els[key].value;
});
localStorage.setItem(storageKey, JSON.stringify(payload));
setStatus("参数已保存");
}
function nextOrderId() {
return "invite-test-" + Date.now();
}
function normalizeBase(base) {
const value = String(base || "").trim() || "/api";
return value.endsWith("/") ? value.slice(0, -1) : value;
}
function authHeader(token) {
const value = String(token || "").trim();
if (!value) {
return "";
}
return /^bearer\s+/i.test(value) ? value : "Bearer " + value;
}
function setStatus(text) {
els.statusText.textContent = text;
}
function formatJSON(value) {
return JSON.stringify(value, null, 2);
}
function unwrap(payload) {
return payload && (payload.body || payload.data || payload);
}
function addLog(title, payload, ok = true) {
const entry = document.createElement("div");
entry.className = "log-entry";
entry.innerHTML = `
<div class="log-title">
<strong>${escapeHtml(title)}</strong>
<span>${ok ? "OK" : "ERROR"} · ${new Date().toLocaleTimeString()}</span>
</div>
<pre>${escapeHtml(formatJSON(payload))}</pre>
`;
els.logs.prepend(entry);
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (match) => {
return {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[match];
});
}
async function request(path, options = {}) {
const base = normalizeBase(els.apiBaseInput.value);
const response = await fetch(base + path, {
method: options.method || "GET",
headers: {
Accept: "application/json",
...(options.headers || {}),
},
body: options.body,
});
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("application/json")
? await response.json()
: await response.text();
if (!response.ok || payload?.status === false) {
const message = payload?.errorMsg || payload?.message || response.statusText;
throw Object.assign(new Error(message), { payload, status: response.status });
}
return payload;
}
async function runAction(action) {
setStatus("请求中:" + action);
saveParams();
try {
if (action === "health") {
const payload = await request("/health");
addLog("health", payload);
}
if (action === "home") {
await fetchHome();
}
if (action === "rank") {
await fetchRank();
}
if (action === "landing") {
await fetchLanding();
}
if (action === "bind") {
await bindCode();
}
if (action === "recharge") {
await rechargeSuccess();
}
if (action === "refresh") {
await fetchHome();
await fetchRank();
}
setStatus("完成:" + action);
} catch (error) {
addLog(action, error.payload || { message: error.message, status: error.status }, false);
setStatus("失败:" + error.message);
}
}
async function fetchHome() {
const token = authHeader(els.inviterTokenInput.value);
if (!token) {
throw new Error("请先填写邀请人 token");
}
const payload = await request("/app/h5/invite-campaign/home", {
headers: { Authorization: token },
});
state.home = unwrap(payload);
addLog("home", payload);
applyHome(state.home);
}
async function fetchRank() {
const token = authHeader(els.inviterTokenInput.value);
if (!token) {
throw new Error("请先填写邀请人 token");
}
const limit = Number(els.rankLimitInput.value || 50);
const payload = await request("/app/h5/invite-campaign/rank?limit=" + encodeURIComponent(limit), {
headers: { Authorization: token },
});
state.rank = unwrap(payload);
addLog("rank", payload);
renderRank(state.rank);
}
async function fetchLanding() {
const code = els.inviteCodeInput.value.trim();
if (!code) {
throw new Error("请先填写邀请码,或先查邀请人首页自动填入");
}
const payload = await request("/public/h5/invite-campaign/landing?code=" + encodeURIComponent(code));
state.landing = unwrap(payload);
addLog("landing", payload);
}
async function bindCode() {
const token = authHeader(els.inviteeTokenInput.value);
const code = els.inviteCodeInput.value.trim();
if (!token) {
throw new Error("请先填写被邀请人 token");
}
if (!code) {
throw new Error("请先填写邀请码");
}
const payload = await request("/app/h5/invite-campaign/bind-code", {
method: "POST",
headers: {
Authorization: token,
"Content-Type": "application/json",
},
body: JSON.stringify({ inviteCode: code }),
});
addLog("bind-code", payload);
}
async function rechargeSuccess() {
const sysOrigin = els.sysOriginInput.value.trim();
const userId = Number(els.inviteeUserIdInput.value || 0);
const rechargeCoins = Number(els.rechargeCoinsInput.value || 0);
const orderId = els.orderIdInput.value.trim() || nextOrderId();
if (!sysOrigin) {
throw new Error("请先填写 sysOrigin或先查首页自动填入");
}
if (!userId) {
throw new Error("请填写被邀请人 userId");
}
if (!rechargeCoins) {
throw new Error("请填写充值金币数量");
}
els.orderIdInput.value = orderId;
const payload = await request("/internal/invite-campaign/recharge-success", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
orderId,
userId,
rechargeCoins,
sysOrigin,
payTime: Date.now(),
}),
});
addLog("recharge-success", payload);
els.orderIdInput.value = nextOrderId();
}
async function claimTask(ruleId) {
const token = authHeader(els.inviterTokenInput.value);
if (!token) {
throw new Error("请先填写邀请人 token");
}
const payload = await request("/app/h5/invite-campaign/tasks/claim", {
method: "POST",
headers: {
Authorization: token,
"Content-Type": "application/json",
},
body: JSON.stringify({ ruleId }),
});
addLog("claim rule " + ruleId, payload);
await fetchHome();
}
function applyHome(home) {
if (!home) {
return;
}
els.inviteCodeInput.value = home.inviteCode || els.inviteCodeInput.value;
els.sysOriginInput.value = home.sysOrigin || els.sysOriginInput.value;
els.inviteLinkOutput.value = home.inviteLink || "";
renderMetrics(home);
renderTasks(home);
startCountdown(home);
}
function renderMetrics(home) {
const stats = home.stats || {};
const monthly = home.monthlyProgress || {};
const values = [
["累计邀请", stats.totalInvitedUsers],
["累计有效", stats.totalValidUsers],
["累计充值", stats.totalRechargeCoins],
["累计奖励", stats.rewardGoldCoins],
["本月邀请", monthly.inviteCount],
["本月有效", monthly.validUserCount],
["本月充值", monthly.totalRechargeCoins],
["本月奖励", monthly.rewardGoldCoins],
];
els.metrics.innerHTML = values
.map(([label, value]) => `
<div class="metric">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(formatNumber(value))}</strong>
</div>
`)
.join("");
}
function renderTasks(home) {
const groups = [
["邀请人数任务", home.inviterValidCountTasks || []],
["总充值任务", home.inviterTotalRechargeTasks || []],
];
els.taskList.innerHTML = groups
.map(([title, tasks]) => `
<div class="task">
<strong>${escapeHtml(title)}</strong>
${tasks.map(renderTask).join("") || '<span class="small">暂无任务</span>'}
</div>
`)
.join("");
}
function renderTask(task) {
const canClaim = task.achieved && !task.claimed;
return `
<div class="task-head">
<div>
<strong>Rule #${escapeHtml(task.ruleId)}</strong>
<div class="task-meta">
进度 ${escapeHtml(formatNumber(task.progress))} / ${escapeHtml(formatNumber(task.threshold))}
· 奖励 ${escapeHtml(formatNumber(task.goldAmount))} 金币
· ${task.claimed ? "已领取" : task.achieved ? "可领取" : "未达标"}
</div>
</div>
<button type="button" ${canClaim ? "" : "disabled"} data-claim="${escapeHtml(task.ruleId)}">
${task.claimed ? "Claimed" : "Claim"}
</button>
</div>
`;
}
function renderRank(rank) {
const items = rank?.items || [];
els.rankList.innerHTML = items.length
? items.map((item) => `
<div class="rank">
<div class="rank-user">
<strong>#${escapeHtml(item.rank)}</strong>
<img class="avatar" src="${escapeHtml(item.avatar || "")}" alt="" />
<div>
<div class="rank-name">${escapeHtml(item.nickname || "User " + item.userId)}</div>
<div class="small">userId: ${escapeHtml(item.userId)}</div>
</div>
</div>
<div class="small">
邀请 ${escapeHtml(formatNumber(item.inviteCount))} 人 · 奖励 ${escapeHtml(formatNumber(item.rewardGoldCoins))}
</div>
</div>
`).join("")
: '<span class="small">暂无排行榜数据</span>';
}
function startCountdown(home) {
window.clearInterval(state.countdownTimer);
const endAt = Number(home.periodEndAtMs || 0);
const serverTime = Number(home.serverTimeMs || Date.now());
const offset = serverTime - Date.now();
const tick = () => {
const diff = Math.max(0, endAt - (Date.now() + offset));
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 || "--") + ",距离重置:" +
days + "天 " + pad(hours) + ":" + pad(minutes) + ":" + pad(secs) +
"periodEndAtMs=" + endAt;
};
tick();
state.countdownTimer = window.setInterval(tick, 1000);
}
function pad(value) {
return String(value).padStart(2, "0");
}
function formatNumber(value) {
const number = Number(value || 0);
return Number.isFinite(number) ? number.toLocaleString("en-US") : "--";
}
document.addEventListener("click", (event) => {
const actionButton = event.target.closest("[data-action]");
if (actionButton) {
runAction(actionButton.dataset.action);
return;
}
const claimButton = event.target.closest("[data-claim]");
if (claimButton) {
claimTask(Number(claimButton.dataset.claim)).catch((error) => {
addLog("claim", error.payload || { message: error.message }, false);
setStatus("领取失败:" + error.message);
});
}
});
document.getElementById("saveButton").addEventListener("click", saveParams);
document.getElementById("resetButton").addEventListener("click", () => {
els.logs.innerHTML = "";
setStatus("日志已清空");
});
loadSavedParams();
renderMetrics({ stats: {}, monthlyProgress: {} });
</script>
</body>
</html>

1033
h5/app-invite/index.html Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 451 KiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 KiB

After

Width:  |  Height:  |  Size: 2.8 MiB

File diff suppressed because one or more lines are too long

407
h5/mifa-pay/app.js Normal file
View File

@ -0,0 +1,407 @@
const query = new URLSearchParams(window.location.search);
const storedAppId = window.localStorage.getItem("mifa-pay-application-id") || "";
const LOGO_SRC = "./mifapay-logo.png";
function getPathApplicationId() {
const segments = window.location.pathname.split("/").filter(Boolean);
const pageIndex = segments.lastIndexOf("mifa-pay");
if (pageIndex === -1) {
return "";
}
return segments[pageIndex + 1] || "";
}
const defaults = {
applicationId:
getPathApplicationId() ||
query.get("applicationId") ||
query.get("appId") ||
storedAppId ||
"2048200000000000701",
productType: query.get("type") || "GOLD",
};
const deps = {
getApi: null,
postApi: null,
fetchCurrentUser: null,
getCachedUserProfile: null,
connectApp: null,
};
const state = {
applicationId: defaults.applicationId,
productType: defaults.productType,
userProfile: null,
countries: [],
selectedCountryId: "",
commodityCard: null,
selectedGoodsId: "",
selectedGoods: null,
pendingChannelCode: "",
dependencyPromise: null,
};
const els = {
applicationId: document.getElementById("applicationId"),
productList: document.getElementById("productList"),
channelList: document.getElementById("channelList"),
pageStatus: document.getElementById("pageStatus"),
};
function setStatus(text = "") {
if (!text) {
els.pageStatus.textContent = "";
els.pageStatus.className = "status";
return;
}
els.pageStatus.textContent = text;
els.pageStatus.className = "status show";
}
function persistSettings() {
window.localStorage.setItem("mifa-pay-application-id", state.applicationId);
}
function extractErrorMessage(error) {
return (
error?.response?.errorMsg ||
error?.response?.body?.errorMsg ||
error?.errorMsg ||
error?.message ||
"Unknown error"
);
}
function readStoredUserProfile() {
const raw = window.localStorage.getItem("userProfile");
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : null;
} catch (error) {
return null;
}
}
function resolveUserProfile(response = null) {
if (response?.body && typeof response.body === "object") {
return response.body;
}
if (typeof deps.getCachedUserProfile === "function") {
const cached = deps.getCachedUserProfile();
if (cached && typeof cached === "object") {
return cached;
}
}
return readStoredUserProfile();
}
function currentUserId() {
return String(state.userProfile?.id || "").trim();
}
function currentCountryId() {
return state.selectedCountryId;
}
function getCountryLabel(item) {
return (
item?.label ||
item?.countryName ||
item?.country?.aliasName ||
item?.country?.countryName ||
String(item?.id || "")
);
}
function getGoodsList() {
return state.commodityCard?.commodity || [];
}
function getChannelList() {
return state.commodityCard?.channels || [];
}
function getChannelCode(item) {
return item?.details?.channelCode || item?.channel?.channelCode || "";
}
function getAmountText(item) {
const content = item?.content || item?.obtainGoldsQuantity || "-";
return `${content} ${item?.type || ""}`.trim();
}
function getPriceText(item) {
const currency = item?.currency || state.commodityCard?.payCountry?.currency || "USD";
return `${item?.amount || item?.amountUsd || "-"} ${currency}`;
}
function ensureSelectedGoods() {
const goods = getGoodsList();
if (!goods.length) {
state.selectedGoodsId = "";
state.selectedGoods = null;
return;
}
const matched =
goods.find((item) => String(item.id) === state.selectedGoodsId) ||
goods.find((item) => item.type === state.productType) ||
goods[0];
state.selectedGoods = matched;
state.selectedGoodsId = String(matched.id);
}
function renderProducts() {
const goods = getGoodsList();
ensureSelectedGoods();
if (!goods.length) {
els.productList.innerHTML = '<div class="empty">No amount available.</div>';
return;
}
els.productList.innerHTML = goods
.map((item) => {
const selected = String(item.id) === state.selectedGoodsId;
return `
<button
type="button"
class="amount-item${selected ? " selected" : ""}"
data-goods-id="${item.id}"
>
<span class="check"></span>
<span class="amount-copy">
<span class="amount-value">${item.content || item.obtainGoldsQuantity || "-"}</span>
<span class="amount-meta">${item.type || "Recharge"}</span>
</span>
<span class="price-copy">
<strong>${item.amount || item.amountUsd || "-"}</strong>
<span>USD ${item.amountUsd || "-"}</span>
</span>
</button>
`;
})
.join("");
els.productList.querySelectorAll("[data-goods-id]").forEach((button) => {
button.addEventListener("click", () => {
const goodsId = button.getAttribute("data-goods-id");
state.selectedGoodsId = goodsId;
state.selectedGoods =
getGoodsList().find((item) => String(item.id) === goodsId) || null;
renderProducts();
});
});
}
function renderChannels() {
const channels = getChannelList();
if (!channels.length) {
els.channelList.innerHTML = '<div class="empty">No payment method available.</div>';
return;
}
els.channelList.innerHTML = channels
.map((item) => {
const code = getChannelCode(item);
const pending = state.pendingChannelCode === code;
return `
<button
type="button"
class="channel-button"
data-channel-code="${code}"
${pending ? "disabled" : ""}
>
<span class="channel-logo">
<img src="${LOGO_SRC}" alt="MiFaPay" />
</span>
<span class="channel-name">${pending ? "Processing..." : "Pay with MiFaPay"}</span>
</button>
`;
})
.join("");
els.channelList.querySelectorAll("[data-channel-code]").forEach((button) => {
button.addEventListener("click", async () => {
const channelCode = button.getAttribute("data-channel-code");
try {
await createOrder(channelCode);
} catch (error) {
state.pendingChannelCode = "";
renderChannels();
setStatus(extractErrorMessage(error));
}
});
});
}
function selectCountryByProfile() {
const userCountryCode = state.userProfile?.countryCode;
if (!userCountryCode) {
return false;
}
const matched = state.countries.find((item) =>
getCountryLabel(item).toUpperCase().includes(userCountryCode.toUpperCase())
);
if (!matched) {
return false;
}
state.selectedCountryId = String(matched.id);
return true;
}
async function ensureDependencies() {
if (!state.dependencyPromise) {
state.dependencyPromise = Promise.all([
import("../js/index-CIAVq8iD-1776148658686.js"),
import("../js/appConnector-B5Pbaops-1776148658686.js"),
]).then(([indexModule, connectorModule]) => {
deps.getApi = indexModule.L;
deps.postApi = indexModule.W;
deps.fetchCurrentUser = indexModule.au;
deps.getCachedUserProfile = indexModule.at;
deps.connectApp = connectorModule.c;
return deps;
});
}
return state.dependencyPromise;
}
async function connectSession() {
await ensureDependencies();
const connection = await deps.connectApp();
if (!connection?.success) {
throw new Error(connection?.error || "Failed to initialize app session");
}
const cachedUserProfile = resolveUserProfile();
if (cachedUserProfile?.id) {
state.userProfile = cachedUserProfile;
return;
}
const response = await deps.fetchCurrentUser();
const userProfile = resolveUserProfile(response);
if (!userProfile?.id) {
throw new Error("Please open this page inside the app");
}
state.userProfile = userProfile;
}
async function refreshMeta() {
if (!state.applicationId) {
throw new Error("Missing applicationId");
}
persistSettings();
els.applicationId.value = state.applicationId;
const countriesResp = await deps.getApi("/order/web/pay/country");
state.countries = Array.isArray(countriesResp?.body) ? countriesResp.body : [];
if (!state.countries.length) {
state.selectedCountryId = "";
state.commodityCard = null;
renderProducts();
renderChannels();
throw new Error("No supported country");
}
if (
!state.selectedCountryId ||
!state.countries.some((item) => String(item.id) === String(state.selectedCountryId))
) {
if (!selectCountryByProfile()) {
state.selectedCountryId = String(state.countries[0].id);
}
}
const commodityResp = await deps.getApi(
`/order/web/pay/commodity?applicationId=${encodeURIComponent(
state.applicationId
)}&payCountryId=${encodeURIComponent(currentCountryId())}&type=${encodeURIComponent(
state.productType
)}`
);
state.commodityCard = commodityResp?.body || {};
renderProducts();
renderChannels();
setStatus("");
}
function safeDecodeUrl(url) {
if (!url) {
return "";
}
try {
return decodeURIComponent(url);
} catch (error) {
return url;
}
}
async function createOrder(channelCode) {
if (!state.selectedGoods) {
throw new Error("Select an amount first");
}
if (!currentUserId()) {
throw new Error("Account not available");
}
state.pendingChannelCode = channelCode;
setStatus("");
renderChannels();
const payload = {
applicationId: Number(state.applicationId),
goodsId: Number(state.selectedGoodsId),
payCountryId: Number(currentCountryId()),
userId: Number(currentUserId()),
channelCode,
newVersion: true,
};
const response = await deps.postApi("/order/web/pay/recharge", payload);
const requestUrl = safeDecodeUrl(response?.body?.requestUrl || "");
if (!requestUrl) {
throw new Error("Missing payment url");
}
window.location.href = requestUrl;
}
async function reloadPageData() {
await ensureDependencies();
await connectSession();
await refreshMeta();
}
async function init() {
renderProducts();
renderChannels();
try {
await reloadPageData();
} catch (error) {
setStatus(extractErrorMessage(error));
}
}
init();

249
h5/mifa-pay/index.html Normal file
View File

@ -0,0 +1,249 @@
<!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>Recharge</title>
<style>
:root {
--bg: #08131f;
--surface: rgba(15, 28, 42, 0.92);
--surface-strong: #102030;
--line: rgba(214, 170, 82, 0.18);
--line-strong: rgba(214, 170, 82, 0.42);
--text: #f6f1e6;
--muted: #bcae8d;
--gold: #d6aa52;
--gold-strong: #c49335;
--danger: #ff9f9f;
--shadow: 0 16px 36px rgba(0, 0, 0, 0.24);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
font-family:
"SF Pro Display", "SF Pro Text", "PingFang SC", "Microsoft YaHei",
sans-serif;
color: var(--text);
background:
radial-gradient(circle at top, rgba(214, 170, 82, 0.1), transparent 24%),
linear-gradient(180deg, #091321 0%, var(--bg) 100%);
}
button {
font: inherit;
border: none;
cursor: pointer;
}
.page {
width: min(100%, 480px);
margin: 0 auto;
padding: 24px 16px 32px;
}
h1 {
margin: 0 0 18px;
font-size: 30px;
line-height: 1;
font-weight: 800;
color: var(--gold);
text-align: center;
letter-spacing: 0.03em;
}
.list {
display: grid;
gap: 12px;
}
.amount-item,
.channel-button {
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-radius: 22px;
border: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(214, 170, 82, 0.04), transparent 60%),
var(--surface);
box-shadow: var(--shadow);
color: var(--text);
text-align: left;
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.amount-item:active,
.channel-button:active {
transform: scale(0.988);
}
.amount-item.selected {
border-color: var(--line-strong);
box-shadow:
0 0 0 1px rgba(214, 170, 82, 0.12),
0 16px 36px rgba(0, 0, 0, 0.24);
}
.amount-copy {
min-width: 0;
display: grid;
gap: 8px;
}
.amount-value {
font-size: 24px;
line-height: 1;
font-weight: 800;
color: var(--text);
}
.amount-meta {
font-size: 11px;
line-height: 1;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.price-copy {
text-align: right;
display: grid;
gap: 6px;
flex-shrink: 0;
}
.price-copy strong {
font-size: 22px;
line-height: 1;
color: var(--gold);
}
.price-copy span {
font-size: 11px;
color: rgba(246, 241, 230, 0.58);
}
.check {
position: absolute;
top: 14px;
right: 14px;
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid rgba(214, 170, 82, 0.3);
}
.amount-item.selected .check {
border-color: transparent;
background: linear-gradient(135deg, #efcf82, var(--gold-strong));
}
.amount-item.selected .check::after {
content: "";
position: absolute;
top: 5px;
left: 5px;
width: 9px;
height: 5px;
border-left: 2px solid #0c1520;
border-bottom: 2px solid #0c1520;
transform: rotate(-45deg);
}
.channel-block {
margin-top: 18px;
}
.channel-button {
justify-content: flex-start;
padding-right: 18px;
}
.channel-logo {
width: 78px;
height: 44px;
display: grid;
place-items: center;
flex-shrink: 0;
border-radius: 14px;
background: #ffffff;
overflow: hidden;
}
.channel-logo img {
display: block;
width: 68px;
height: auto;
}
.channel-name {
font-size: 16px;
font-weight: 700;
color: var(--text);
}
.channel-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.status {
display: none;
margin-top: 14px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(255, 159, 159, 0.18);
background: rgba(255, 159, 159, 0.08);
color: var(--danger);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
}
.status.show {
display: block;
}
.empty {
padding: 16px;
border-radius: 20px;
border: 1px dashed var(--line);
color: var(--muted);
text-align: center;
font-size: 13px;
}
</style>
</head>
<body>
<div class="page">
<h1>Recharge</h1>
<div id="productList" class="list"></div>
<div id="channelList" class="list channel-block"></div>
<div id="pageStatus" class="status" aria-live="polite"></div>
</div>
<input id="applicationId" type="hidden" />
<script type="module" src="./app.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -0,0 +1,120 @@
<!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 User To Become Agent</title>
<link rel="stylesheet" href="./yumi-invite-agency.css" />
</head>
<body>
<div class="fullPage" id="pageRoot">
<div class="status-bar-placeholder" id="statusBarPlaceholder"></div>
<div class="header-container">
<div class="side-area">
<button class="back-btn" id="backButton" type="button">
<img class="flip-img" id="backButtonIcon" src="" alt="" style="width: 100%; aspect-ratio: 1 / 1; display: block" />
</button>
</div>
<h1 class="header-title" id="pageTitle" style="color: black">Invite User To Become Agent</h1>
<div class="actions-area">
<div class="language-entry" id="languageEntry">
<div class="language-name" id="languageName">English</div>
</div>
</div>
</div>
<div style="padding: 16px; position: relative">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
<div
class="gradient-border-wrapper"
style="
flex: 1;
min-width: 0;
border-radius: 4px;
--gradient-border-width: 1.5px;
--gradient-direction: to bottom;
--gradient-start-color: #ffc4c4;
--gradient-end-color: #fff6a6;
--gradient-background: linear-gradient(120deg, rgba(255, 216, 0, 0.1) 39.13%, rgba(255, 149, 0, 0.1) 119.28%);
"
>
<div class="gradient-border-content">
<div style="padding: 0 8px; display: flex; align-items: center; height: max-content">
<input
id="searchInput"
type="text"
name=""
placeholder="Enter User ID"
autocomplete="off"
style="
width: 100%;
color: rgba(0, 0, 0, 0.4);
font-weight: 600;
outline: none;
border: none;
background-color: transparent;
padding: 8px;
"
/>
<button
id="clearButton"
type="button"
hidden
style="background: none; border: none; color: #9ca3af; cursor: pointer; padding: 0 4px"
>
×
</button>
</div>
</div>
</div>
<button style="font-weight: 600" class="invite-btn" id="searchButton" type="button" disabled>Search</button>
</div>
<div id="resultList" style="margin: 16px 0; display: flex; flex-direction: column; gap: 12px"></div>
<div id="noData" class="no-data">please input user id</div>
</div>
</div>
<template id="userCardTemplate">
<div
class="gradient-border-wrapper user-card"
style="
position: relative;
z-index: 0;
border-radius: 20px;
--gradient-border-width: 1.5px;
--gradient-direction: to bottom;
--gradient-start-color: #ffc4c4;
--gradient-end-color: #fff6a6;
--gradient-background: linear-gradient(120deg, rgba(255, 216, 0, 0.1) 39.13%, rgba(255, 149, 0, 0.1) 119.28%);
"
>
<div class="gradient-border-content">
<div style="padding: 10px; transition: all 0.2s">
<div class="status-badge" style="position: absolute; z-index: 3; top: -2px; right: -2px"></div>
<div style="font-weight: 600">Information:</div>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 4px">
<div style="flex: 1; min-width: 0; align-self: stretch; display: flex; align-items: center; gap: 4px">
<div style="width: 3em; display: flex; align-items: center; justify-content: center">
<img class="avatar-image" src="" alt="" style="width: 100%; aspect-ratio: 1/1; object-fit: cover; border-radius: 50%" />
</div>
<div style="flex: 1; min-width: 0; align-self: stretch">
<div class="nickname" style="margin-bottom: 4px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"></div>
<div class="account" style="font-size: 0.9em; color: rgba(0, 0, 0, 0.4); font-weight: 500"></div>
</div>
</div>
<div style="width: auto; min-width: 0; align-self: stretch; display: flex; justify-content: center; align-items: center">
<div class="pending-actions"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script src="./yumi-invite-agency.js"></script>
</body>
</html>

View File

@ -0,0 +1,456 @@
html {
-webkit-text-size-adjust: 100%;
-webkit-overflow-scrolling: touch;
}
body {
margin: 0;
min-height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
}
* {
box-sizing: border-box;
}
button,
input {
font: inherit;
}
[hidden] {
display: none !important;
}
.fullPage {
width: 100vw;
min-height: 100vh;
background-color: #fff;
background-image: url("../assets/secondBg-CSqvFWr0-1776148661447.png");
background-size: 100% auto;
background-repeat: no-repeat;
position: relative;
color: rgba(0, 0, 0, 0.8);
}
.fullPage input::placeholder {
font-weight: 700;
color: rgba(0, 0, 0, 0.4);
}
.invite-btn {
padding: 4px 12px;
border-radius: 32px;
background: linear-gradient(269deg, #feb219 7.04%, #ff9326 96.78%);
color: #fff;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.invite-btn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.no-data {
text-align: center;
padding: 40px 20px;
color: #666;
}
.user-card {
cursor: default;
}
.result-action-btn {
padding: 4px 10px;
border-radius: 32px;
background: linear-gradient(269deg, #feb219 7.04%, #ff9326 96.78%);
color: #fff;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.result-action-btn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.status-bar-placeholder {
display: none;
height: 30px;
}
.status-bar-placeholder.is-visible {
display: block;
}
.header-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
height: 60px;
font-size: 16px;
}
.side-area,
.actions-area {
min-width: 1.5em;
display: flex;
align-items: center;
}
.actions-area {
justify-content: flex-end;
gap: 8px;
}
.header-title {
flex: 1;
margin: 0;
font-size: 1.2em;
font-weight: 600;
text-align: center;
letter-spacing: 0.3px;
}
.back-btn,
.help-btn {
width: 1.5em;
border: none;
background: transparent;
transition: all 0.2s ease;
padding: 0;
}
.back-btn:active,
.help-btn:active {
transform: scale(0.9);
}
.language-entry {
display: flex;
align-items: center;
color: #f59e0b !important;
cursor: pointer;
}
.language-name {
font-weight: 700;
}
.gradient-border-wrapper {
position: relative;
}
.gradient-border-wrapper::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: var(--gradient-border-width, 1.5px);
background: linear-gradient(
var(--gradient-direction, to bottom),
var(--gradient-start-color, #ffc4c4),
var(--gradient-end-color, #fff6a6)
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.gradient-border-content {
position: relative;
border-radius: inherit;
width: 100%;
height: 100%;
background: var(
--gradient-background,
linear-gradient(120deg, rgba(255, 216, 0, 0.1) 39.13%, rgba(255, 149, 0, 0.1) 119.28%)
);
}
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 20px;
}
.modal {
background: #fff;
border-radius: 16px;
box-shadow:
0 20px 25px rgba(0, 0, 0, 0.1),
0 10px 10px rgba(0, 0, 0, 0.04);
width: 100%;
max-width: 320px;
padding: 24px 20px 20px;
text-align: center;
position: relative;
}
.modal-icon {
margin-bottom: 16px;
display: flex;
justify-content: center;
}
.icon-circle {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
animation: iconPulse 0.6s ease-out;
border: 3px solid;
}
.icon-symbol {
font-size: 24px;
font-weight: 700;
line-height: 1;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin-bottom: 12px;
line-height: 1.4;
}
.modal-message {
font-size: 14px;
color: #6b7280;
line-height: 1.5;
margin-bottom: 20px;
word-wrap: break-word;
}
.modal-actions {
display: flex;
justify-content: center;
}
.modal-btn {
border: none;
border-radius: 8px;
padding: 10px 32px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
color: #fff;
}
.modal-btn:active {
transform: scale(0.98);
}
.modal-error .icon-circle-error {
background-color: #fee2e2;
border-color: #fecaca;
}
.modal-error .icon-symbol-error {
color: #dc2626;
}
.modal-error .modal-btn-error {
background-color: #dc2626;
}
.modal-warning .icon-circle-warning {
background-color: #fef3c7;
border-color: #fde68a;
}
.modal-warning .icon-symbol-warning {
color: #d97706;
}
.modal-warning .modal-btn-warning {
background-color: #d97706;
}
.modal-success .icon-circle-success {
background-color: #d1fae5;
border-color: #a7f3d0;
}
.modal-success .icon-symbol-success {
color: #059669;
}
.modal-success .modal-btn-success {
background-color: #059669;
}
.modal-info .icon-circle-info {
background-color: #dbeafe;
border-color: #bfdbfe;
}
.modal-info .icon-symbol-info {
color: #2563eb;
}
.modal-info .modal-btn-info {
background-color: #2563eb;
}
.modal-simple .modal-message {
font-size: 15px;
color: #374151;
margin-bottom: 24px;
}
.modal-simple .modal-btn {
background-color: #2563eb;
}
.modal-enter {
animation: modalEnter 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-leave {
animation: modalLeave 0.2s ease-in;
}
[dir="rtl"] .flip-img {
transform: scaleX(-1);
}
[dir="rtl"] .avatar {
margin-right: 0;
margin-left: 12px;
}
[dir="rtl"] .user-info {
text-align: right;
}
[dir="rtl"] .language-entry,
[dir="rtl"] .header-title {
direction: rtl;
}
@keyframes modalEnter {
0% {
opacity: 0;
transform: scale(0.7) translateY(-10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modalLeave {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
100% {
opacity: 0;
transform: scale(0.9) translateY(-5px);
}
}
@keyframes iconPulse {
0% {
transform: scale(0.8);
opacity: 0.5;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media screen and (max-width: 360px) {
.fullPage {
font-size: 10px;
}
.header-container {
font-size: 10px;
}
}
@media screen and (min-width: 360px) {
.fullPage {
font-size: 12px;
}
.header-container {
font-size: 16px;
}
}
@media screen and (min-width: 768px) {
.fullPage {
font-size: 24px;
}
.header-container {
font-size: 24px;
}
}
@media screen and (min-width: 1024px) {
.fullPage {
font-size: 32px;
}
.header-container {
font-size: 32px;
}
}
@media (max-width: 480px) {
.modal {
max-width: 280px;
padding: 20px 16px 16px;
}
.icon-circle {
width: 48px;
height: 48px;
}
.icon-symbol {
font-size: 20px;
}
.modal-title {
font-size: 16px;
}
.modal-message {
font-size: 13px;
}
}

View File

@ -0,0 +1,694 @@
(function () {
const API_BASE = "https://jvapi.haiyihy.com";
const BACK_ICON =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAGXSURBVHgB7dvBTcNAFIThB5xTQFwADVBBmqEnUgZQAAXAjRqgACgAj2wfECbJZXZnrPmkVRT5lF9ODn4vVRERERERETEaxnNb8cd+PA/jeZ3PY02xqG7KA+Ic6/eds5vfPxfRdelb4uxXrt0VmXqgU3Hgo8iUA52LA9SvF1yVpkviHOdDpRhIJg6oBZKKA0qB5OKASiDJOKAQSDYO9A4kHQd6BpKPA70CWcSBHoFs4kDrQFZxoGUguzjQKpBlHGgRyDYOsANZxwFmIPs4wAq0iTjACvRUG4gDjEeumDRsIg4wAn3VhjDmYt81jWOGf64vo5q3MsAaHOLDH2oa7q2xicQKhLvopTYQiTl63kQk9mzePlKL5QXrSK22O2wjtVx/sYzUej/ILlKPBSqrSL02zGwi9VzBs4jUe0dRPpLCEqd0JJUtV9lISmvAkpHU9qTlIikukktFUt20vzQSHu++F5HqGvDi3PgIgQ5FpL5p/zme+/l1za7IHP6rcSoSfdPeIRCsRcIPtM18rSUMJoeKiIiIiIjw9QNKh3OJhFQBoAAAAABJRU5ErkJggg==";
const DEFAULT_AVATAR = "../assets/defaultAvatar-CdxWBK1k-1776148661448.png";
const TEXT = {
pageTitle: "Invite User To Become Agent",
language: "English",
placeholder: "Enter User ID",
search: "Search",
invite: "Invite",
idlePrompt: "please input user id",
noUsersFound: "No users found",
information: "Information:",
userIdPrefix: "User ID:",
pending: "Pending",
success: "Success",
cancel: "Cancel",
invitationSubmitted: "Invitation submitted",
applicationCancelled: "Application cancelled",
somethingWentWrong: "Something went wrong",
cancellationFailed: "Cancellation failed, try again",
informationTitle: "Information",
successTitle: "Success",
errorTitle: "Error",
ok: "OK",
};
const state = {
query: "",
invitedList: [],
searchResults: [],
showSearchResults: false,
searching: false,
hasSearched: false,
};
const dom = {
statusBarPlaceholder: document.getElementById("statusBarPlaceholder"),
backButton: document.getElementById("backButton"),
backButtonIcon: document.getElementById("backButtonIcon"),
pageTitle: document.getElementById("pageTitle"),
languageEntry: document.getElementById("languageEntry"),
languageName: document.getElementById("languageName"),
searchInput: document.getElementById("searchInput"),
clearButton: document.getElementById("clearButton"),
searchButton: document.getElementById("searchButton"),
resultList: document.getElementById("resultList"),
noData: document.getElementById("noData"),
userCardTemplate: document.getElementById("userCardTemplate"),
};
init();
function init() {
document.title = TEXT.pageTitle;
document.documentElement.lang = "en";
document.documentElement.dir = resolveDocumentDirection();
dom.backButtonIcon.src = BACK_ICON;
dom.pageTitle.textContent = TEXT.pageTitle;
dom.languageName.textContent = TEXT.language;
dom.searchInput.placeholder = TEXT.placeholder;
dom.searchButton.textContent = TEXT.search;
dom.noData.textContent = TEXT.idlePrompt;
syncStatusBarPlaceholder();
bindEvents();
render();
loadInvitedList();
}
function bindEvents() {
dom.backButton.addEventListener("click", handleBack);
dom.languageEntry.addEventListener("click", handleLanguageClick);
dom.searchInput.addEventListener("input", handleSearchInput);
dom.clearButton.addEventListener("click", clearSearch);
dom.searchButton.addEventListener("click", triggerSearchNow);
}
function resolveDocumentDirection() {
const lang = (new URLSearchParams(window.location.search).get("lang") || "").toLowerCase();
return lang.startsWith("ar") ? "rtl" : "ltr";
}
function syncStatusBarPlaceholder() {
const hasAppBridge =
Boolean(window.app) ||
Boolean(window.FlutterPageControl) ||
Boolean(window.webkit && window.webkit.messageHandlers);
const isIOS = /iphone|ipad|ipod/i.test(window.navigator.userAgent || "");
dom.statusBarPlaceholder.classList.toggle("is-visible", hasAppBridge && isIOS);
}
function handleBack() {
if (window.history.length > 1) {
window.history.back();
return;
}
window.location.href = "/";
}
function handleLanguageClick() {
window.location.href = "/language";
}
function handleSearchInput(event) {
state.query = event.target.value;
dom.clearButton.hidden = !state.query;
state.showSearchResults = false;
state.hasSearched = false;
render();
}
function clearSearch() {
state.query = "";
state.searchResults = [];
state.showSearchResults = false;
state.hasSearched = false;
dom.searchInput.value = "";
dom.clearButton.hidden = true;
render();
}
async function triggerSearchNow() {
if (state.searching) {
return;
}
const query = state.query.trim();
if (!query) {
state.showSearchResults = false;
state.hasSearched = false;
render();
return;
}
state.searching = true;
state.showSearchResults = true;
state.hasSearched = true;
state.searchResults = [];
render();
try {
const payload = await searchUser(query);
state.searchResults = payload ? [mergeWithInvitationStatus(payload.userProfile)] : [];
} catch (error) {
state.searchResults = [];
showToast(extractErrorMessage(error), {
title: TEXT.errorTitle,
type: "error",
});
} finally {
state.searching = false;
render();
}
}
async function loadInvitedList() {
try {
const response = await request("GET", "/team/bd/invite-message-own");
state.invitedList = response && response.status && Array.isArray(response.body) ? response.body : [];
state.searchResults = state.searchResults.map(function (item) {
return mergeWithInvitationStatus(item.userProfile || {});
});
render();
} catch (error) {
state.invitedList = [];
render();
}
}
async function searchUser(query) {
try {
const response = await request(
"GET",
"/user/user-profile/open-search?account=" + encodeURIComponent(query)
);
if (response && response.status && response.body) {
return { userProfile: response.body };
}
} catch (error) {
if (!/^\d+$/.test(query)) {
throw error;
}
}
if (!/^\d+$/.test(query)) {
return null;
}
const fallbackResponse = await request(
"GET",
"/user/user-profile?userId=" + encodeURIComponent(query)
);
if (fallbackResponse && fallbackResponse.status && fallbackResponse.body) {
return { userProfile: fallbackResponse.body };
}
return null;
}
function mergeWithInvitationStatus(userProfile) {
const matchedInvitation = state.invitedList.find(function (item) {
return (
item &&
item.userProfile &&
userProfile &&
item.userProfile.id === userProfile.id
);
});
return Object.assign({}, matchedInvitation || {}, {
userProfile: userProfile || {},
});
}
async function inviteUser(item) {
if (!item || !item.userProfile || !item.userProfile.id) {
return;
}
try {
const response = await request("POST", "/team/bd/invite-agent", {
body: {
inviteUserId: item.userProfile.id,
},
});
if (response && response.status) {
showToast(TEXT.invitationSubmitted, {
title: TEXT.successTitle,
type: "success",
});
await loadInvitedList();
}
} catch (error) {
showToast(extractErrorMessage(error, TEXT.somethingWentWrong), {
title: TEXT.errorTitle,
type: "error",
});
}
}
async function cancelInvitation(invitationId) {
try {
const response = await request("POST", "/team/bd/invite-agent-cancel/" + invitationId);
if (response && response.status) {
showToast(TEXT.applicationCancelled, {
title: TEXT.informationTitle,
type: "info",
});
await loadInvitedList();
}
} catch (error) {
showToast(extractErrorMessage(error, TEXT.cancellationFailed), {
title: TEXT.errorTitle,
type: "error",
});
}
}
function render() {
renderSearchButton();
renderList();
}
function renderSearchButton() {
dom.searchButton.disabled = !state.query.trim() || state.searching;
}
function renderList() {
const currentList = state.showSearchResults ? state.searchResults : state.invitedList;
dom.resultList.innerHTML = "";
currentList.forEach(function (item) {
dom.resultList.appendChild(createUserCard(item, state.showSearchResults ? "search" : "invited"));
});
if (currentList.length > 0 || (state.searching && state.showSearchResults)) {
dom.noData.hidden = true;
return;
}
dom.noData.hidden = false;
dom.noData.textContent =
state.showSearchResults && state.hasSearched ? TEXT.noUsersFound : TEXT.idlePrompt;
}
function createUserCard(item, mode) {
const fragment = dom.userCardTemplate.content.cloneNode(true);
const statusBadge = fragment.querySelector(".status-badge");
const avatarImage = fragment.querySelector(".avatar-image");
const nickname = fragment.querySelector(".nickname");
const account = fragment.querySelector(".account");
const pendingActions = fragment.querySelector(".pending-actions");
const userProfile = item.userProfile || {};
avatarImage.src = userProfile.userAvatar || DEFAULT_AVATAR;
avatarImage.alt = userProfile.userNickname || "";
avatarImage.onerror = handleAvatarError;
nickname.textContent = userProfile.userNickname || "";
account.textContent = TEXT.userIdPrefix + " " + (userProfile.account || userProfile.id || "");
if (item.status === 0) {
statusBadge.innerHTML =
'<div style="background-image: linear-gradient(112deg, #759cff 5.66%, #3d73ff 42.49%); padding: 4px 8px; border-radius: 0 12px; color: white; font-weight: 600">' +
TEXT.pending +
"</div>";
} else if (item.status === 1) {
statusBadge.innerHTML =
'<div style="background-image: linear-gradient(112deg, #75ff98 5.66%, #3dff54 42.49%); padding: 4px 8px; border-radius: 0 12px; color: white; font-weight: 600">' +
TEXT.success +
"</div>";
}
if (mode === "search") {
const inviteButton = document.createElement("button");
inviteButton.type = "button";
inviteButton.className = "result-action-btn";
inviteButton.textContent = TEXT.invite;
inviteButton.disabled = item.status === 0 || item.status === 1;
inviteButton.addEventListener("click", function (event) {
event.stopPropagation();
inviteUser(item);
});
pendingActions.appendChild(inviteButton);
} else if (item.status === 0) {
const cancelButton = document.createElement("div");
cancelButton.textContent = TEXT.cancel;
cancelButton.style.padding = "4px 8px";
cancelButton.style.borderRadius = "32px";
cancelButton.style.backgroundColor = "red";
cancelButton.style.color = "white";
cancelButton.style.fontWeight = "600";
cancelButton.style.zIndex = "5";
cancelButton.addEventListener("click", function (event) {
event.stopPropagation();
cancelInvitation(item.id);
});
pendingActions.appendChild(cancelButton);
}
return fragment;
}
function handleAvatarError(event) {
event.target.onerror = null;
event.target.src = DEFAULT_AVATAR;
}
function extractErrorMessage(error, fallback) {
return (
(error &&
error.response &&
(error.response.errorMsg || error.response.message || error.response.error)) ||
(error && error.message) ||
fallback ||
TEXT.somethingWentWrong
);
}
function showToast(message, options) {
const config = Object.assign(
{
title: TEXT.informationTitle,
type: "info",
confirmText: TEXT.ok,
},
options || {}
);
const iconMap = {
error: "✕",
warning: "⚠",
success: "✓",
info: "i",
simple: "",
};
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML =
'<div class="modal modal-enter modal-' +
config.type +
'">' +
(config.type === "simple"
? ""
: '<div class="modal-icon"><div class="icon-circle icon-circle-' +
config.type +
'"><span class="icon-symbol icon-symbol-' +
config.type +
'">' +
iconMap[config.type] +
"</span></div></div>") +
(config.type === "simple" || !config.title
? ""
: '<div class="modal-title">' + escapeHtml(config.title) + "</div>") +
'<div class="modal-message">' +
escapeHtml(message) +
"</div>" +
'<div class="modal-actions"><button class="modal-btn modal-btn-' +
config.type +
'">' +
escapeHtml(config.confirmText) +
"</button></div>" +
"</div>";
const modal = overlay.querySelector(".modal");
const closeButton = overlay.querySelector(".modal-btn");
const close = function () {
modal.classList.remove("modal-enter");
modal.classList.add("modal-leave");
window.setTimeout(function () {
overlay.remove();
}, 200);
};
closeButton.addEventListener("click", close);
overlay.addEventListener("click", function (event) {
if (event.target === overlay) {
close();
}
});
document.body.appendChild(overlay);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
async function request(method, path, options) {
const config = options || {};
const headers = buildRequestHeaders(Boolean(config.body), config.headers || {});
const response = await fetch(API_BASE + path, {
method: method,
credentials: "include",
headers: headers,
body: config.body ? JSON.stringify(config.body) : undefined,
});
const text = await response.text();
let payload = {};
if (text) {
try {
payload = JSON.parse(text);
} catch (error) {
payload = {
status: response.ok,
body: text,
};
}
}
if (!response.ok || payload.status === false) {
const requestError = new Error(
payload.errorMsg || payload.message || response.statusText || TEXT.somethingWentWrong
);
requestError.response = payload;
throw requestError;
}
return payload;
}
function buildRequestHeaders(hasBody, overrideHeaders) {
const discovered = discoverAppHeaders();
const headers = Object.assign({}, overrideHeaders || {});
if (hasBody) {
headers["Content-Type"] = headers["Content-Type"] || "application/json";
}
headers.Accept = headers.Accept || "application/json, text/plain, */*";
if (discovered.authorization) {
headers.Authorization = headers.Authorization || discovered.authorization;
}
if (discovered.reqLang) {
headers["Req-Lang"] = headers["Req-Lang"] || discovered.reqLang;
}
if (discovered.appVersion) {
headers["App-Version"] = headers["App-Version"] || discovered.appVersion;
}
if (discovered.buildVersion) {
headers["Build-Version"] = headers["Build-Version"] || discovered.buildVersion;
}
if (discovered.appChannel) {
headers["App-Channel"] = headers["App-Channel"] || discovered.appChannel;
}
if (discovered.reqImei) {
headers["Req-Imei"] = headers["Req-Imei"] || discovered.reqImei;
}
if (discovered.sysOrigin) {
headers["Sys-Origin"] = headers["Sys-Origin"] || discovered.sysOrigin;
}
if (discovered.sysOriginChild) {
headers["Sys-Origin-Child"] =
headers["Sys-Origin-Child"] || discovered.sysOriginChild;
}
return headers;
}
function discoverAppHeaders() {
const query = new URLSearchParams(window.location.search);
const collected = [];
collectCandidate(collected, window.__APP_HEADERS__);
collectCandidate(collected, window.__HEADER_INFO__);
collectCandidate(collected, window.headerInfo);
collectCandidate(collected, parseHeaderObjectFromQuery(query));
collectFromStorage(collected, window.localStorage);
collectFromStorage(collected, window.sessionStorage);
return collected.reduce(function (result, candidate) {
return Object.assign(result, candidate);
}, {
reqLang: query.get("lang") || document.documentElement.lang || "en",
});
}
function parseHeaderObjectFromQuery(query) {
const candidate = {};
mapHeaderValue(candidate, "authorization", query.get("authorization"));
mapHeaderValue(candidate, "authorization", query.get("token"));
mapHeaderValue(candidate, "reqLang", query.get("lang"));
mapHeaderValue(candidate, "appVersion", query.get("appVersion"));
mapHeaderValue(candidate, "buildVersion", query.get("buildVersion"));
mapHeaderValue(candidate, "appChannel", query.get("appChannel"));
mapHeaderValue(candidate, "reqImei", query.get("reqImei"));
mapHeaderValue(candidate, "sysOrigin", query.get("sysOrigin"));
mapHeaderValue(candidate, "sysOriginChild", query.get("sysOriginChild"));
return candidate;
}
function collectFromStorage(collected, storage) {
if (!storage) {
return;
}
const preferredKeys = [
"authorization",
"Authorization",
"token",
"accessToken",
"headerInfo",
"headersFromApp",
"appHeaders",
"persist:root",
"user",
"userInfo",
];
preferredKeys.forEach(function (key) {
try {
collectCandidate(collected, storage.getItem(key));
} catch (error) {
return;
}
});
for (let index = 0; index < storage.length; index += 1) {
try {
const key = storage.key(index);
collectCandidate(collected, storage.getItem(key));
} catch (error) {
return;
}
}
}
function collectCandidate(collected, value) {
const normalized = normalizeHeaderCandidate(value, 0);
if (normalized && Object.keys(normalized).length > 0) {
collected.push(normalized);
}
}
function normalizeHeaderCandidate(value, depth) {
if (depth > 4 || value == null) {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed[0] === "{" || trimmed[0] === "[") {
try {
return normalizeHeaderCandidate(JSON.parse(trimmed), depth + 1);
} catch (error) {
return null;
}
}
if (/bearer\s+/i.test(trimmed)) {
return {
authorization: trimmed,
};
}
return null;
}
if (Array.isArray(value)) {
return value.reduce(function (result, entry) {
const normalized = normalizeHeaderCandidate(entry, depth + 1);
return normalized ? Object.assign(result, normalized) : result;
}, {});
}
if (typeof value !== "object") {
return null;
}
const normalized = {};
const aliasMap = {
authorization: ["authorization", "Authorization", "token", "accessToken"],
reqLang: ["reqLang", "Req-Lang", "lang", "language"],
appVersion: ["appVersion", "App-Version"],
buildVersion: ["buildVersion", "Build-Version"],
appChannel: ["appChannel", "App-Channel"],
reqImei: ["reqImei", "Req-Imei"],
sysOrigin: ["sysOrigin", "Sys-Origin"],
sysOriginChild: ["sysOriginChild", "Sys-Origin-Child"],
};
Object.keys(aliasMap).forEach(function (targetKey) {
aliasMap[targetKey].forEach(function (sourceKey) {
mapHeaderValue(normalized, targetKey, value[sourceKey]);
});
});
["headerInfo", "headers", "auth", "data", "state"].forEach(function (nestedKey) {
const nestedValue = normalizeHeaderCandidate(value[nestedKey], depth + 1);
if (nestedValue) {
Object.assign(normalized, nestedValue);
}
});
Object.keys(value).forEach(function (key) {
if (
key === "headerInfo" ||
key === "headers" ||
key === "auth" ||
key === "data" ||
key === "state"
) {
return;
}
if (typeof value[key] === "object") {
const nestedValue = normalizeHeaderCandidate(value[key], depth + 1);
if (nestedValue) {
Object.assign(normalized, nestedValue);
}
}
});
return normalized;
}
function mapHeaderValue(target, key, value) {
if (value == null || value === "" || target[key]) {
return;
}
target[key] = String(value);
}
})();

View File

@ -0,0 +1,119 @@
<!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 User To Become BD Leader</title>
<link rel="stylesheet" href="./yumi-invite-bd-leader.css" />
</head>
<body>
<div class="fullPage" id="pageRoot">
<div class="status-bar-placeholder" id="statusBarPlaceholder"></div>
<div class="header-container">
<div class="side-area">
<button class="back-btn" id="backButton" type="button">
<img class="flip-img" id="backButtonIcon" src="" alt="" style="width: 100%; aspect-ratio: 1 / 1; display: block" />
</button>
</div>
<h1 class="header-title" id="pageTitle" style="color: black">Invite User To Become BD Leader</h1>
<div class="actions-area">
<div class="language-entry" id="languageEntry">
<div class="language-name" id="languageName">English</div>
</div>
</div>
</div>
<div style="padding: 16px; position: relative">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
<div
class="gradient-border-wrapper"
style="
flex: 1;
min-width: 0;
border-radius: 4px;
--gradient-border-width: 1.5px;
--gradient-direction: to bottom;
--gradient-start-color: #ffc4c4;
--gradient-end-color: #fff6a6;
--gradient-background: linear-gradient(120deg, rgba(255, 216, 0, 0.1) 39.13%, rgba(255, 149, 0, 0.1) 119.28%);
"
>
<div class="gradient-border-content">
<div style="padding: 0 8px; display: flex; align-items: center; height: max-content">
<input
id="searchInput"
type="text"
placeholder="Enter User ID"
autocomplete="off"
style="
width: 100%;
color: rgba(0, 0, 0, 0.4);
font-weight: 600;
outline: none;
border: none;
background-color: transparent;
padding: 8px;
"
/>
<button
id="clearButton"
type="button"
hidden
style="background: none; border: none; color: #9ca3af; cursor: pointer; padding: 0 4px"
>
×
</button>
</div>
</div>
</div>
<button style="font-weight: 600" class="invite-btn" id="searchButton" type="button" disabled>Search</button>
</div>
<div id="resultList" style="margin: 16px 0; display: flex; flex-direction: column; gap: 12px"></div>
<div id="noData" class="no-data">please input user id</div>
</div>
</div>
<template id="userCardTemplate">
<div
class="gradient-border-wrapper user-card"
style="
position: relative;
z-index: 0;
border-radius: 20px;
--gradient-border-width: 1.5px;
--gradient-direction: to bottom;
--gradient-start-color: #ffc4c4;
--gradient-end-color: #fff6a6;
--gradient-background: linear-gradient(120deg, rgba(255, 216, 0, 0.1) 39.13%, rgba(255, 149, 0, 0.1) 119.28%);
"
>
<div class="gradient-border-content">
<div style="padding: 10px; transition: all 0.2s">
<div class="status-badge" style="position: absolute; z-index: 3; top: -2px; right: -2px"></div>
<div style="font-weight: 600">Information:</div>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 4px">
<div style="flex: 1; min-width: 0; align-self: stretch; display: flex; align-items: center; gap: 4px">
<div style="width: 3em; display: flex; align-items: center; justify-content: center">
<img class="avatar-image" src="" alt="" style="width: 100%; aspect-ratio: 1/1; object-fit: cover; border-radius: 50%" />
</div>
<div style="flex: 1; min-width: 0; align-self: stretch">
<div class="nickname" style="margin-bottom: 4px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"></div>
<div class="account" style="font-size: 0.9em; color: rgba(0, 0, 0, 0.4); font-weight: 500"></div>
</div>
</div>
<div style="width: auto; min-width: 0; align-self: stretch; display: flex; justify-content: center; align-items: center">
<div class="pending-actions"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script src="./yumi-invite-bd-leader.js"></script>
</body>
</html>

View File

@ -0,0 +1,456 @@
html {
-webkit-text-size-adjust: 100%;
-webkit-overflow-scrolling: touch;
}
body {
margin: 0;
min-height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
}
* {
box-sizing: border-box;
}
button,
input {
font: inherit;
}
[hidden] {
display: none !important;
}
.fullPage {
width: 100vw;
min-height: 100vh;
background-color: #fff;
background-image: url("../assets/secondBg-CSqvFWr0-1776148661447.png");
background-size: 100% auto;
background-repeat: no-repeat;
position: relative;
color: rgba(0, 0, 0, 0.8);
}
.fullPage input::placeholder {
font-weight: 700;
color: rgba(0, 0, 0, 0.4);
}
.invite-btn {
padding: 4px 12px;
border-radius: 32px;
background: linear-gradient(269deg, #feb219 7.04%, #ff9326 96.78%);
color: #fff;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.invite-btn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.no-data {
text-align: center;
padding: 40px 20px;
color: #666;
}
.user-card {
cursor: default;
}
.result-action-btn {
padding: 4px 10px;
border-radius: 32px;
background: linear-gradient(269deg, #feb219 7.04%, #ff9326 96.78%);
color: #fff;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.result-action-btn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.status-bar-placeholder {
display: none;
height: 30px;
}
.status-bar-placeholder.is-visible {
display: block;
}
.header-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
height: 60px;
font-size: 16px;
}
.side-area,
.actions-area {
min-width: 1.5em;
display: flex;
align-items: center;
}
.actions-area {
justify-content: flex-end;
gap: 8px;
}
.header-title {
flex: 1;
margin: 0;
font-size: 1.2em;
font-weight: 600;
text-align: center;
letter-spacing: 0.3px;
}
.back-btn,
.help-btn {
width: 1.5em;
border: none;
background: transparent;
transition: all 0.2s ease;
padding: 0;
}
.back-btn:active,
.help-btn:active {
transform: scale(0.9);
}
.language-entry {
display: flex;
align-items: center;
color: #f59e0b !important;
cursor: pointer;
}
.language-name {
font-weight: 700;
}
.gradient-border-wrapper {
position: relative;
}
.gradient-border-wrapper::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: var(--gradient-border-width, 1.5px);
background: linear-gradient(
var(--gradient-direction, to bottom),
var(--gradient-start-color, #ffc4c4),
var(--gradient-end-color, #fff6a6)
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.gradient-border-content {
position: relative;
border-radius: inherit;
width: 100%;
height: 100%;
background: var(
--gradient-background,
linear-gradient(120deg, rgba(255, 216, 0, 0.1) 39.13%, rgba(255, 149, 0, 0.1) 119.28%)
);
}
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 20px;
}
.modal {
background: #fff;
border-radius: 16px;
box-shadow:
0 20px 25px rgba(0, 0, 0, 0.1),
0 10px 10px rgba(0, 0, 0, 0.04);
width: 100%;
max-width: 320px;
padding: 24px 20px 20px;
text-align: center;
position: relative;
}
.modal-icon {
margin-bottom: 16px;
display: flex;
justify-content: center;
}
.icon-circle {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
animation: iconPulse 0.6s ease-out;
border: 3px solid;
}
.icon-symbol {
font-size: 24px;
font-weight: 700;
line-height: 1;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin-bottom: 12px;
line-height: 1.4;
}
.modal-message {
font-size: 14px;
color: #6b7280;
line-height: 1.5;
margin-bottom: 20px;
word-wrap: break-word;
}
.modal-actions {
display: flex;
justify-content: center;
}
.modal-btn {
border: none;
border-radius: 8px;
padding: 10px 32px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
color: #fff;
}
.modal-btn:active {
transform: scale(0.98);
}
.modal-error .icon-circle-error {
background-color: #fee2e2;
border-color: #fecaca;
}
.modal-error .icon-symbol-error {
color: #dc2626;
}
.modal-error .modal-btn-error {
background-color: #dc2626;
}
.modal-warning .icon-circle-warning {
background-color: #fef3c7;
border-color: #fde68a;
}
.modal-warning .icon-symbol-warning {
color: #d97706;
}
.modal-warning .modal-btn-warning {
background-color: #d97706;
}
.modal-success .icon-circle-success {
background-color: #d1fae5;
border-color: #a7f3d0;
}
.modal-success .icon-symbol-success {
color: #059669;
}
.modal-success .modal-btn-success {
background-color: #059669;
}
.modal-info .icon-circle-info {
background-color: #dbeafe;
border-color: #bfdbfe;
}
.modal-info .icon-symbol-info {
color: #2563eb;
}
.modal-info .modal-btn-info {
background-color: #2563eb;
}
.modal-simple .modal-message {
font-size: 15px;
color: #374151;
margin-bottom: 24px;
}
.modal-simple .modal-btn {
background-color: #2563eb;
}
.modal-enter {
animation: modalEnter 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-leave {
animation: modalLeave 0.2s ease-in;
}
[dir="rtl"] .flip-img {
transform: scaleX(-1);
}
[dir="rtl"] .avatar {
margin-right: 0;
margin-left: 12px;
}
[dir="rtl"] .user-info {
text-align: right;
}
[dir="rtl"] .language-entry,
[dir="rtl"] .header-title {
direction: rtl;
}
@keyframes modalEnter {
0% {
opacity: 0;
transform: scale(0.7) translateY(-10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modalLeave {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
100% {
opacity: 0;
transform: scale(0.9) translateY(-5px);
}
}
@keyframes iconPulse {
0% {
transform: scale(0.8);
opacity: 0.5;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media screen and (max-width: 360px) {
.fullPage {
font-size: 10px;
}
.header-container {
font-size: 10px;
}
}
@media screen and (min-width: 360px) {
.fullPage {
font-size: 12px;
}
.header-container {
font-size: 16px;
}
}
@media screen and (min-width: 768px) {
.fullPage {
font-size: 24px;
}
.header-container {
font-size: 24px;
}
}
@media screen and (min-width: 1024px) {
.fullPage {
font-size: 32px;
}
.header-container {
font-size: 32px;
}
}
@media (max-width: 480px) {
.modal {
max-width: 280px;
padding: 20px 16px 16px;
}
.icon-circle {
width: 48px;
height: 48px;
}
.icon-symbol {
font-size: 20px;
}
.modal-title {
font-size: 16px;
}
.modal-message {
font-size: 13px;
}
}

View File

@ -0,0 +1,699 @@
(function () {
const API_BASE = "https://jvapi.haiyihy.com";
const BACK_ICON =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAGXSURBVHgB7dvBTcNAFIThB5xTQFwADVBBmqEnUgZQAAXAjRqgACgAj2wfECbJZXZnrPmkVRT5lF9ODn4vVRERERERETEaxnNb8cd+PA/jeZ3PY02xqG7KA+Ic6/eds5vfPxfRdelb4uxXrt0VmXqgU3Hgo8iUA52LA9SvF1yVpkviHOdDpRhIJg6oBZKKA0qB5OKASiDJOKAQSDYO9A4kHQd6BpKPA70CWcSBHoFs4kDrQFZxoGUguzjQKpBlHGgRyDYOsANZxwFmIPs4wAq0iTjACvRUG4gDjEeumDRsIg4wAn3VhjDmYt81jWOGf64vo5q3MsAaHOLDH2oa7q2xicQKhLvopTYQiTl63kQk9mzePlKL5QXrSK22O2wjtVx/sYzUej/ILlKPBSqrSL02zGwi9VzBs4jUe0dRPpLCEqd0JJUtV9lISmvAkpHU9qTlIikukktFUt20vzQSHu++F5HqGvDi3PgIgQ5FpL5p/zme+/l1za7IHP6rcSoSfdPeIRCsRcIPtM18rSUMJoeKiIiIiIjw9QNKh3OJhFQBoAAAAABJRU5ErkJggg==";
const DEFAULT_AVATAR = "../assets/defaultAvatar-CdxWBK1k-1776148661448.png";
const ENDPOINTS = {
invitedList: "/team/bd/invite-bdleader-message-own",
invite: "/team/bd/invite-bdleader",
cancelPrefix: "/team/bd/invite-bdleader-cancel/",
};
const TEXT = {
pageTitle: "Invite User To Become BD Leader",
language: "English",
placeholder: "Enter User ID",
search: "Search",
invite: "Invite",
idlePrompt: "please input user id",
noUsersFound: "No users found",
information: "Information:",
userIdPrefix: "User ID:",
pending: "Pending",
success: "Success",
cancel: "Cancel",
invitationSubmitted: "Invitation submitted",
applicationCancelled: "Application cancelled",
somethingWentWrong: "Something went wrong",
cancellationFailed: "Cancellation failed, try again",
informationTitle: "Information",
successTitle: "Success",
errorTitle: "Error",
ok: "OK",
};
const state = {
query: "",
invitedList: [],
searchResults: [],
showSearchResults: false,
searching: false,
hasSearched: false,
};
const dom = {
statusBarPlaceholder: document.getElementById("statusBarPlaceholder"),
backButton: document.getElementById("backButton"),
backButtonIcon: document.getElementById("backButtonIcon"),
pageTitle: document.getElementById("pageTitle"),
languageEntry: document.getElementById("languageEntry"),
languageName: document.getElementById("languageName"),
searchInput: document.getElementById("searchInput"),
clearButton: document.getElementById("clearButton"),
searchButton: document.getElementById("searchButton"),
resultList: document.getElementById("resultList"),
noData: document.getElementById("noData"),
userCardTemplate: document.getElementById("userCardTemplate"),
};
init();
function init() {
document.title = TEXT.pageTitle;
document.documentElement.lang = "en";
document.documentElement.dir = resolveDocumentDirection();
dom.backButtonIcon.src = BACK_ICON;
dom.pageTitle.textContent = TEXT.pageTitle;
dom.languageName.textContent = TEXT.language;
dom.searchInput.placeholder = TEXT.placeholder;
dom.searchButton.textContent = TEXT.search;
dom.noData.textContent = TEXT.idlePrompt;
syncStatusBarPlaceholder();
bindEvents();
render();
loadInvitedList();
}
function bindEvents() {
dom.backButton.addEventListener("click", handleBack);
dom.languageEntry.addEventListener("click", handleLanguageClick);
dom.searchInput.addEventListener("input", handleSearchInput);
dom.clearButton.addEventListener("click", clearSearch);
dom.searchButton.addEventListener("click", triggerSearchNow);
}
function resolveDocumentDirection() {
const lang = (new URLSearchParams(window.location.search).get("lang") || "").toLowerCase();
return lang.startsWith("ar") ? "rtl" : "ltr";
}
function syncStatusBarPlaceholder() {
const hasAppBridge =
Boolean(window.app) ||
Boolean(window.FlutterPageControl) ||
Boolean(window.webkit && window.webkit.messageHandlers);
const isIOS = /iphone|ipad|ipod/i.test(window.navigator.userAgent || "");
dom.statusBarPlaceholder.classList.toggle("is-visible", hasAppBridge && isIOS);
}
function handleBack() {
if (window.history.length > 1) {
window.history.back();
return;
}
window.location.href = "/";
}
function handleLanguageClick() {
window.location.href = "/language";
}
function handleSearchInput(event) {
state.query = event.target.value;
dom.clearButton.hidden = !state.query;
state.showSearchResults = false;
state.hasSearched = false;
render();
}
function clearSearch() {
state.query = "";
state.searchResults = [];
state.showSearchResults = false;
state.hasSearched = false;
dom.searchInput.value = "";
dom.clearButton.hidden = true;
render();
}
async function triggerSearchNow() {
if (state.searching) {
return;
}
const query = state.query.trim();
if (!query) {
state.showSearchResults = false;
state.hasSearched = false;
render();
return;
}
state.searching = true;
state.showSearchResults = true;
state.hasSearched = true;
state.searchResults = [];
render();
try {
const payload = await searchUser(query);
state.searchResults = payload ? [mergeWithInvitationStatus(payload.userProfile)] : [];
} catch (error) {
state.searchResults = [];
showToast(extractErrorMessage(error), {
title: TEXT.errorTitle,
type: "error",
});
} finally {
state.searching = false;
render();
}
}
async function loadInvitedList() {
try {
const response = await request("GET", ENDPOINTS.invitedList);
state.invitedList = response && response.status && Array.isArray(response.body) ? response.body : [];
state.searchResults = state.searchResults.map(function (item) {
return mergeWithInvitationStatus(item.userProfile || {});
});
render();
} catch (error) {
state.invitedList = [];
render();
}
}
async function searchUser(query) {
try {
const response = await request(
"GET",
"/user/user-profile/open-search?account=" + encodeURIComponent(query)
);
if (response && response.status && response.body) {
return { userProfile: response.body };
}
} catch (error) {
if (!/^\d+$/.test(query)) {
throw error;
}
}
if (!/^\d+$/.test(query)) {
return null;
}
const fallbackResponse = await request(
"GET",
"/user/user-profile?userId=" + encodeURIComponent(query)
);
if (fallbackResponse && fallbackResponse.status && fallbackResponse.body) {
return { userProfile: fallbackResponse.body };
}
return null;
}
function mergeWithInvitationStatus(userProfile) {
const matchedInvitation = state.invitedList.find(function (item) {
return (
item &&
item.userProfile &&
userProfile &&
item.userProfile.id === userProfile.id
);
});
return Object.assign({}, matchedInvitation || {}, {
userProfile: userProfile || {},
});
}
async function inviteUser(item) {
if (!item || !item.userProfile || !item.userProfile.id) {
return;
}
try {
const response = await request("POST", ENDPOINTS.invite, {
body: {
inviteUserId: item.userProfile.id,
},
});
if (response && response.status) {
showToast(TEXT.invitationSubmitted, {
title: TEXT.successTitle,
type: "success",
});
await loadInvitedList();
}
} catch (error) {
showToast(extractErrorMessage(error, TEXT.somethingWentWrong), {
title: TEXT.errorTitle,
type: "error",
});
}
}
async function cancelInvitation(invitationId) {
try {
const response = await request("POST", ENDPOINTS.cancelPrefix + invitationId);
if (response && response.status) {
showToast(TEXT.applicationCancelled, {
title: TEXT.informationTitle,
type: "info",
});
await loadInvitedList();
}
} catch (error) {
showToast(extractErrorMessage(error, TEXT.cancellationFailed), {
title: TEXT.errorTitle,
type: "error",
});
}
}
function render() {
renderSearchButton();
renderList();
}
function renderSearchButton() {
dom.searchButton.disabled = !state.query.trim() || state.searching;
}
function renderList() {
const currentList = state.showSearchResults ? state.searchResults : state.invitedList;
dom.resultList.innerHTML = "";
currentList.forEach(function (item) {
dom.resultList.appendChild(createUserCard(item, state.showSearchResults ? "search" : "invited"));
});
if (currentList.length > 0 || (state.searching && state.showSearchResults)) {
dom.noData.hidden = true;
return;
}
dom.noData.hidden = false;
dom.noData.textContent =
state.showSearchResults && state.hasSearched ? TEXT.noUsersFound : TEXT.idlePrompt;
}
function createUserCard(item, mode) {
const fragment = dom.userCardTemplate.content.cloneNode(true);
const statusBadge = fragment.querySelector(".status-badge");
const avatarImage = fragment.querySelector(".avatar-image");
const nickname = fragment.querySelector(".nickname");
const account = fragment.querySelector(".account");
const pendingActions = fragment.querySelector(".pending-actions");
const userProfile = item.userProfile || {};
avatarImage.src = userProfile.userAvatar || DEFAULT_AVATAR;
avatarImage.alt = userProfile.userNickname || "";
avatarImage.onerror = handleAvatarError;
nickname.textContent = userProfile.userNickname || "";
account.textContent = TEXT.userIdPrefix + " " + (userProfile.account || userProfile.id || "");
if (item.status === 0) {
statusBadge.innerHTML =
'<div style="background-image: linear-gradient(112deg, #759cff 5.66%, #3d73ff 42.49%); padding: 4px 8px; border-radius: 0 12px; color: white; font-weight: 600">' +
TEXT.pending +
"</div>";
} else if (item.status === 1) {
statusBadge.innerHTML =
'<div style="background-image: linear-gradient(112deg, #75ff98 5.66%, #3dff54 42.49%); padding: 4px 8px; border-radius: 0 12px; color: white; font-weight: 600">' +
TEXT.success +
"</div>";
}
if (mode === "search") {
const inviteButton = document.createElement("button");
inviteButton.type = "button";
inviteButton.className = "result-action-btn";
inviteButton.textContent = TEXT.invite;
inviteButton.disabled = item.status === 0 || item.status === 1;
inviteButton.addEventListener("click", function (event) {
event.stopPropagation();
inviteUser(item);
});
pendingActions.appendChild(inviteButton);
} else if (item.status === 0) {
const cancelButton = document.createElement("div");
cancelButton.textContent = TEXT.cancel;
cancelButton.style.padding = "4px 8px";
cancelButton.style.borderRadius = "32px";
cancelButton.style.backgroundColor = "red";
cancelButton.style.color = "white";
cancelButton.style.fontWeight = "600";
cancelButton.style.zIndex = "5";
cancelButton.addEventListener("click", function (event) {
event.stopPropagation();
cancelInvitation(item.id);
});
pendingActions.appendChild(cancelButton);
}
return fragment;
}
function handleAvatarError(event) {
event.target.onerror = null;
event.target.src = DEFAULT_AVATAR;
}
function extractErrorMessage(error, fallback) {
return (
(error &&
error.response &&
(error.response.errorMsg || error.response.message || error.response.error)) ||
(error && error.message) ||
fallback ||
TEXT.somethingWentWrong
);
}
function showToast(message, options) {
const config = Object.assign(
{
title: TEXT.informationTitle,
type: "info",
confirmText: TEXT.ok,
},
options || {}
);
const iconMap = {
error: "✕",
warning: "⚠",
success: "✓",
info: "i",
simple: "",
};
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML =
'<div class="modal modal-enter modal-' +
config.type +
'">' +
(config.type === "simple"
? ""
: '<div class="modal-icon"><div class="icon-circle icon-circle-' +
config.type +
'"><span class="icon-symbol icon-symbol-' +
config.type +
'">' +
iconMap[config.type] +
"</span></div></div>") +
(config.type === "simple" || !config.title
? ""
: '<div class="modal-title">' + escapeHtml(config.title) + "</div>") +
'<div class="modal-message">' +
escapeHtml(message) +
"</div>" +
'<div class="modal-actions"><button class="modal-btn modal-btn-' +
config.type +
'">' +
escapeHtml(config.confirmText) +
"</button></div>" +
"</div>";
const modal = overlay.querySelector(".modal");
const closeButton = overlay.querySelector(".modal-btn");
const close = function () {
modal.classList.remove("modal-enter");
modal.classList.add("modal-leave");
window.setTimeout(function () {
overlay.remove();
}, 200);
};
closeButton.addEventListener("click", close);
overlay.addEventListener("click", function (event) {
if (event.target === overlay) {
close();
}
});
document.body.appendChild(overlay);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
async function request(method, path, options) {
const config = options || {};
const headers = buildRequestHeaders(Boolean(config.body), config.headers || {});
const response = await fetch(API_BASE + path, {
method: method,
credentials: "include",
headers: headers,
body: config.body ? JSON.stringify(config.body) : undefined,
});
const text = await response.text();
let payload = {};
if (text) {
try {
payload = JSON.parse(text);
} catch (error) {
payload = {
status: response.ok,
body: text,
};
}
}
if (!response.ok || payload.status === false) {
const requestError = new Error(
payload.errorMsg || payload.message || response.statusText || TEXT.somethingWentWrong
);
requestError.response = payload;
throw requestError;
}
return payload;
}
function buildRequestHeaders(hasBody, overrideHeaders) {
const discovered = discoverAppHeaders();
const headers = Object.assign({}, overrideHeaders || {});
if (hasBody) {
headers["Content-Type"] = headers["Content-Type"] || "application/json";
}
headers.Accept = headers.Accept || "application/json, text/plain, */*";
if (discovered.authorization) {
headers.Authorization = headers.Authorization || discovered.authorization;
}
if (discovered.reqLang) {
headers["Req-Lang"] = headers["Req-Lang"] || discovered.reqLang;
}
if (discovered.appVersion) {
headers["App-Version"] = headers["App-Version"] || discovered.appVersion;
}
if (discovered.buildVersion) {
headers["Build-Version"] = headers["Build-Version"] || discovered.buildVersion;
}
if (discovered.appChannel) {
headers["App-Channel"] = headers["App-Channel"] || discovered.appChannel;
}
if (discovered.reqImei) {
headers["Req-Imei"] = headers["Req-Imei"] || discovered.reqImei;
}
if (discovered.sysOrigin) {
headers["Sys-Origin"] = headers["Sys-Origin"] || discovered.sysOrigin;
}
if (discovered.sysOriginChild) {
headers["Sys-Origin-Child"] =
headers["Sys-Origin-Child"] || discovered.sysOriginChild;
}
return headers;
}
function discoverAppHeaders() {
const query = new URLSearchParams(window.location.search);
const collected = [];
collectCandidate(collected, window.__APP_HEADERS__);
collectCandidate(collected, window.__HEADER_INFO__);
collectCandidate(collected, window.headerInfo);
collectCandidate(collected, parseHeaderObjectFromQuery(query));
collectFromStorage(collected, window.localStorage);
collectFromStorage(collected, window.sessionStorage);
return collected.reduce(function (result, candidate) {
return Object.assign(result, candidate);
}, {
reqLang: query.get("lang") || document.documentElement.lang || "en",
});
}
function parseHeaderObjectFromQuery(query) {
const candidate = {};
mapHeaderValue(candidate, "authorization", query.get("authorization"));
mapHeaderValue(candidate, "authorization", query.get("token"));
mapHeaderValue(candidate, "reqLang", query.get("lang"));
mapHeaderValue(candidate, "appVersion", query.get("appVersion"));
mapHeaderValue(candidate, "buildVersion", query.get("buildVersion"));
mapHeaderValue(candidate, "appChannel", query.get("appChannel"));
mapHeaderValue(candidate, "reqImei", query.get("reqImei"));
mapHeaderValue(candidate, "sysOrigin", query.get("sysOrigin"));
mapHeaderValue(candidate, "sysOriginChild", query.get("sysOriginChild"));
return candidate;
}
function collectFromStorage(collected, storage) {
if (!storage) {
return;
}
const preferredKeys = [
"authorization",
"Authorization",
"token",
"accessToken",
"headerInfo",
"headersFromApp",
"appHeaders",
"persist:root",
"user",
"userInfo",
];
preferredKeys.forEach(function (key) {
try {
collectCandidate(collected, storage.getItem(key));
} catch (error) {
return;
}
});
for (let index = 0; index < storage.length; index += 1) {
try {
const key = storage.key(index);
collectCandidate(collected, storage.getItem(key));
} catch (error) {
return;
}
}
}
function collectCandidate(collected, value) {
const normalized = normalizeHeaderCandidate(value, 0);
if (normalized && Object.keys(normalized).length > 0) {
collected.push(normalized);
}
}
function normalizeHeaderCandidate(value, depth) {
if (depth > 4 || value == null) {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed[0] === "{" || trimmed[0] === "[") {
try {
return normalizeHeaderCandidate(JSON.parse(trimmed), depth + 1);
} catch (error) {
return null;
}
}
if (/bearer\\s+/i.test(trimmed)) {
return {
authorization: trimmed,
};
}
return null;
}
if (Array.isArray(value)) {
return value.reduce(function (result, entry) {
const normalized = normalizeHeaderCandidate(entry, depth + 1);
return normalized ? Object.assign(result, normalized) : result;
}, {});
}
if (typeof value !== "object") {
return null;
}
const normalized = {};
const aliasMap = {
authorization: ["authorization", "Authorization", "token", "accessToken"],
reqLang: ["reqLang", "Req-Lang", "lang", "language"],
appVersion: ["appVersion", "App-Version"],
buildVersion: ["buildVersion", "Build-Version"],
appChannel: ["appChannel", "App-Channel"],
reqImei: ["reqImei", "Req-Imei"],
sysOrigin: ["sysOrigin", "Sys-Origin"],
sysOriginChild: ["sysOriginChild", "Sys-Origin-Child"],
};
Object.keys(aliasMap).forEach(function (targetKey) {
aliasMap[targetKey].forEach(function (sourceKey) {
mapHeaderValue(normalized, targetKey, value[sourceKey]);
});
});
["headerInfo", "headers", "auth", "data", "state"].forEach(function (nestedKey) {
const nestedValue = normalizeHeaderCandidate(value[nestedKey], depth + 1);
if (nestedValue) {
Object.assign(normalized, nestedValue);
}
});
Object.keys(value).forEach(function (key) {
if (
key === "headerInfo" ||
key === "headers" ||
key === "auth" ||
key === "data" ||
key === "state"
) {
return;
}
if (typeof value[key] === "object") {
const nestedValue = normalizeHeaderCandidate(value[key], depth + 1);
if (nestedValue) {
Object.assign(normalized, nestedValue);
}
}
});
return normalized;
}
function mapHeaderValue(target, key, value) {
if (value == null || value === "" || target[key]) {
return;
}
target[key] = String(value);
}
})();

View File

@ -0,0 +1,119 @@
<!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 User To Become BD</title>
<link rel="stylesheet" href="./yumi-invite-bd.css" />
</head>
<body>
<div class="fullPage" id="pageRoot">
<div class="status-bar-placeholder" id="statusBarPlaceholder"></div>
<div class="header-container">
<div class="side-area">
<button class="back-btn" id="backButton" type="button">
<img class="flip-img" id="backButtonIcon" src="" alt="" style="width: 100%; aspect-ratio: 1 / 1; display: block" />
</button>
</div>
<h1 class="header-title" id="pageTitle" style="color: black">Invite User To Become BD</h1>
<div class="actions-area">
<div class="language-entry" id="languageEntry">
<div class="language-name" id="languageName">English</div>
</div>
</div>
</div>
<div style="padding: 16px; position: relative">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
<div
class="gradient-border-wrapper"
style="
flex: 1;
min-width: 0;
border-radius: 4px;
--gradient-border-width: 1.5px;
--gradient-direction: to bottom;
--gradient-start-color: #ffc4c4;
--gradient-end-color: #fff6a6;
--gradient-background: linear-gradient(120deg, rgba(255, 216, 0, 0.1) 39.13%, rgba(255, 149, 0, 0.1) 119.28%);
"
>
<div class="gradient-border-content">
<div style="padding: 0 8px; display: flex; align-items: center; height: max-content">
<input
id="searchInput"
type="text"
placeholder="Enter User ID"
autocomplete="off"
style="
width: 100%;
color: rgba(0, 0, 0, 0.4);
font-weight: 600;
outline: none;
border: none;
background-color: transparent;
padding: 8px;
"
/>
<button
id="clearButton"
type="button"
hidden
style="background: none; border: none; color: #9ca3af; cursor: pointer; padding: 0 4px"
>
×
</button>
</div>
</div>
</div>
<button style="font-weight: 600" class="invite-btn" id="searchButton" type="button" disabled>Search</button>
</div>
<div id="resultList" style="margin: 16px 0; display: flex; flex-direction: column; gap: 12px"></div>
<div id="noData" class="no-data">please input user id</div>
</div>
</div>
<template id="userCardTemplate">
<div
class="gradient-border-wrapper user-card"
style="
position: relative;
z-index: 0;
border-radius: 20px;
--gradient-border-width: 1.5px;
--gradient-direction: to bottom;
--gradient-start-color: #ffc4c4;
--gradient-end-color: #fff6a6;
--gradient-background: linear-gradient(120deg, rgba(255, 216, 0, 0.1) 39.13%, rgba(255, 149, 0, 0.1) 119.28%);
"
>
<div class="gradient-border-content">
<div style="padding: 10px; transition: all 0.2s">
<div class="status-badge" style="position: absolute; z-index: 3; top: -2px; right: -2px"></div>
<div style="font-weight: 600">Information:</div>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 4px">
<div style="flex: 1; min-width: 0; align-self: stretch; display: flex; align-items: center; gap: 4px">
<div style="width: 3em; display: flex; align-items: center; justify-content: center">
<img class="avatar-image" src="" alt="" style="width: 100%; aspect-ratio: 1/1; object-fit: cover; border-radius: 50%" />
</div>
<div style="flex: 1; min-width: 0; align-self: stretch">
<div class="nickname" style="margin-bottom: 4px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"></div>
<div class="account" style="font-size: 0.9em; color: rgba(0, 0, 0, 0.4); font-weight: 500"></div>
</div>
</div>
<div style="width: auto; min-width: 0; align-self: stretch; display: flex; justify-content: center; align-items: center">
<div class="pending-actions"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script src="./yumi-invite-bd.js"></script>
</body>
</html>

View File

@ -0,0 +1,456 @@
html {
-webkit-text-size-adjust: 100%;
-webkit-overflow-scrolling: touch;
}
body {
margin: 0;
min-height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
}
* {
box-sizing: border-box;
}
button,
input {
font: inherit;
}
[hidden] {
display: none !important;
}
.fullPage {
width: 100vw;
min-height: 100vh;
background-color: #fff;
background-image: url("../assets/secondBg-CSqvFWr0-1776148661447.png");
background-size: 100% auto;
background-repeat: no-repeat;
position: relative;
color: rgba(0, 0, 0, 0.8);
}
.fullPage input::placeholder {
font-weight: 700;
color: rgba(0, 0, 0, 0.4);
}
.invite-btn {
padding: 4px 12px;
border-radius: 32px;
background: linear-gradient(269deg, #feb219 7.04%, #ff9326 96.78%);
color: #fff;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.invite-btn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.no-data {
text-align: center;
padding: 40px 20px;
color: #666;
}
.user-card {
cursor: default;
}
.result-action-btn {
padding: 4px 10px;
border-radius: 32px;
background: linear-gradient(269deg, #feb219 7.04%, #ff9326 96.78%);
color: #fff;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.result-action-btn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.status-bar-placeholder {
display: none;
height: 30px;
}
.status-bar-placeholder.is-visible {
display: block;
}
.header-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
height: 60px;
font-size: 16px;
}
.side-area,
.actions-area {
min-width: 1.5em;
display: flex;
align-items: center;
}
.actions-area {
justify-content: flex-end;
gap: 8px;
}
.header-title {
flex: 1;
margin: 0;
font-size: 1.2em;
font-weight: 600;
text-align: center;
letter-spacing: 0.3px;
}
.back-btn,
.help-btn {
width: 1.5em;
border: none;
background: transparent;
transition: all 0.2s ease;
padding: 0;
}
.back-btn:active,
.help-btn:active {
transform: scale(0.9);
}
.language-entry {
display: flex;
align-items: center;
color: #f59e0b !important;
cursor: pointer;
}
.language-name {
font-weight: 700;
}
.gradient-border-wrapper {
position: relative;
}
.gradient-border-wrapper::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: var(--gradient-border-width, 1.5px);
background: linear-gradient(
var(--gradient-direction, to bottom),
var(--gradient-start-color, #ffc4c4),
var(--gradient-end-color, #fff6a6)
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.gradient-border-content {
position: relative;
border-radius: inherit;
width: 100%;
height: 100%;
background: var(
--gradient-background,
linear-gradient(120deg, rgba(255, 216, 0, 0.1) 39.13%, rgba(255, 149, 0, 0.1) 119.28%)
);
}
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 20px;
}
.modal {
background: #fff;
border-radius: 16px;
box-shadow:
0 20px 25px rgba(0, 0, 0, 0.1),
0 10px 10px rgba(0, 0, 0, 0.04);
width: 100%;
max-width: 320px;
padding: 24px 20px 20px;
text-align: center;
position: relative;
}
.modal-icon {
margin-bottom: 16px;
display: flex;
justify-content: center;
}
.icon-circle {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
animation: iconPulse 0.6s ease-out;
border: 3px solid;
}
.icon-symbol {
font-size: 24px;
font-weight: 700;
line-height: 1;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin-bottom: 12px;
line-height: 1.4;
}
.modal-message {
font-size: 14px;
color: #6b7280;
line-height: 1.5;
margin-bottom: 20px;
word-wrap: break-word;
}
.modal-actions {
display: flex;
justify-content: center;
}
.modal-btn {
border: none;
border-radius: 8px;
padding: 10px 32px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
color: #fff;
}
.modal-btn:active {
transform: scale(0.98);
}
.modal-error .icon-circle-error {
background-color: #fee2e2;
border-color: #fecaca;
}
.modal-error .icon-symbol-error {
color: #dc2626;
}
.modal-error .modal-btn-error {
background-color: #dc2626;
}
.modal-warning .icon-circle-warning {
background-color: #fef3c7;
border-color: #fde68a;
}
.modal-warning .icon-symbol-warning {
color: #d97706;
}
.modal-warning .modal-btn-warning {
background-color: #d97706;
}
.modal-success .icon-circle-success {
background-color: #d1fae5;
border-color: #a7f3d0;
}
.modal-success .icon-symbol-success {
color: #059669;
}
.modal-success .modal-btn-success {
background-color: #059669;
}
.modal-info .icon-circle-info {
background-color: #dbeafe;
border-color: #bfdbfe;
}
.modal-info .icon-symbol-info {
color: #2563eb;
}
.modal-info .modal-btn-info {
background-color: #2563eb;
}
.modal-simple .modal-message {
font-size: 15px;
color: #374151;
margin-bottom: 24px;
}
.modal-simple .modal-btn {
background-color: #2563eb;
}
.modal-enter {
animation: modalEnter 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-leave {
animation: modalLeave 0.2s ease-in;
}
[dir="rtl"] .flip-img {
transform: scaleX(-1);
}
[dir="rtl"] .avatar {
margin-right: 0;
margin-left: 12px;
}
[dir="rtl"] .user-info {
text-align: right;
}
[dir="rtl"] .language-entry,
[dir="rtl"] .header-title {
direction: rtl;
}
@keyframes modalEnter {
0% {
opacity: 0;
transform: scale(0.7) translateY(-10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modalLeave {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
100% {
opacity: 0;
transform: scale(0.9) translateY(-5px);
}
}
@keyframes iconPulse {
0% {
transform: scale(0.8);
opacity: 0.5;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media screen and (max-width: 360px) {
.fullPage {
font-size: 10px;
}
.header-container {
font-size: 10px;
}
}
@media screen and (min-width: 360px) {
.fullPage {
font-size: 12px;
}
.header-container {
font-size: 16px;
}
}
@media screen and (min-width: 768px) {
.fullPage {
font-size: 24px;
}
.header-container {
font-size: 24px;
}
}
@media screen and (min-width: 1024px) {
.fullPage {
font-size: 32px;
}
.header-container {
font-size: 32px;
}
}
@media (max-width: 480px) {
.modal {
max-width: 280px;
padding: 20px 16px 16px;
}
.icon-circle {
width: 48px;
height: 48px;
}
.icon-symbol {
font-size: 20px;
}
.modal-title {
font-size: 16px;
}
.modal-message {
font-size: 13px;
}
}

View File

@ -0,0 +1,699 @@
(function () {
const API_BASE = "https://jvapi.haiyihy.com";
const BACK_ICON =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAGXSURBVHgB7dvBTcNAFIThB5xTQFwADVBBmqEnUgZQAAXAjRqgACgAj2wfECbJZXZnrPmkVRT5lF9ODn4vVRERERERETEaxnNb8cd+PA/jeZ3PY02xqG7KA+Ic6/eds5vfPxfRdelb4uxXrt0VmXqgU3Hgo8iUA52LA9SvF1yVpkviHOdDpRhIJg6oBZKKA0qB5OKASiDJOKAQSDYO9A4kHQd6BpKPA70CWcSBHoFs4kDrQFZxoGUguzjQKpBlHGgRyDYOsANZxwFmIPs4wAq0iTjACvRUG4gDjEeumDRsIg4wAn3VhjDmYt81jWOGf64vo5q3MsAaHOLDH2oa7q2xicQKhLvopTYQiTl63kQk9mzePlKL5QXrSK22O2wjtVx/sYzUej/ILlKPBSqrSL02zGwi9VzBs4jUe0dRPpLCEqd0JJUtV9lISmvAkpHU9qTlIikukktFUt20vzQSHu++F5HqGvDi3PgIgQ5FpL5p/zme+/l1za7IHP6rcSoSfdPeIRCsRcIPtM18rSUMJoeKiIiIiIjw9QNKh3OJhFQBoAAAAABJRU5ErkJggg==";
const DEFAULT_AVATAR = "../assets/defaultAvatar-CdxWBK1k-1776148661448.png";
const ENDPOINTS = {
invitedList: "/team/bd/invite-bd-message-own",
invite: "/team/bd/invite-bd",
cancelPrefix: "/team/bd/invite-bd-cancel/",
};
const TEXT = {
pageTitle: "Invite User To Become BD",
language: "English",
placeholder: "Enter User ID",
search: "Search",
invite: "Invite",
idlePrompt: "please input user id",
noUsersFound: "No users found",
information: "Information:",
userIdPrefix: "User ID:",
pending: "Pending",
success: "Success",
cancel: "Cancel",
invitationSubmitted: "Invitation submitted",
applicationCancelled: "Application cancelled",
somethingWentWrong: "Something went wrong",
cancellationFailed: "Cancellation failed, try again",
informationTitle: "Information",
successTitle: "Success",
errorTitle: "Error",
ok: "OK",
};
const state = {
query: "",
invitedList: [],
searchResults: [],
showSearchResults: false,
searching: false,
hasSearched: false,
};
const dom = {
statusBarPlaceholder: document.getElementById("statusBarPlaceholder"),
backButton: document.getElementById("backButton"),
backButtonIcon: document.getElementById("backButtonIcon"),
pageTitle: document.getElementById("pageTitle"),
languageEntry: document.getElementById("languageEntry"),
languageName: document.getElementById("languageName"),
searchInput: document.getElementById("searchInput"),
clearButton: document.getElementById("clearButton"),
searchButton: document.getElementById("searchButton"),
resultList: document.getElementById("resultList"),
noData: document.getElementById("noData"),
userCardTemplate: document.getElementById("userCardTemplate"),
};
init();
function init() {
document.title = TEXT.pageTitle;
document.documentElement.lang = "en";
document.documentElement.dir = resolveDocumentDirection();
dom.backButtonIcon.src = BACK_ICON;
dom.pageTitle.textContent = TEXT.pageTitle;
dom.languageName.textContent = TEXT.language;
dom.searchInput.placeholder = TEXT.placeholder;
dom.searchButton.textContent = TEXT.search;
dom.noData.textContent = TEXT.idlePrompt;
syncStatusBarPlaceholder();
bindEvents();
render();
loadInvitedList();
}
function bindEvents() {
dom.backButton.addEventListener("click", handleBack);
dom.languageEntry.addEventListener("click", handleLanguageClick);
dom.searchInput.addEventListener("input", handleSearchInput);
dom.clearButton.addEventListener("click", clearSearch);
dom.searchButton.addEventListener("click", triggerSearchNow);
}
function resolveDocumentDirection() {
const lang = (new URLSearchParams(window.location.search).get("lang") || "").toLowerCase();
return lang.startsWith("ar") ? "rtl" : "ltr";
}
function syncStatusBarPlaceholder() {
const hasAppBridge =
Boolean(window.app) ||
Boolean(window.FlutterPageControl) ||
Boolean(window.webkit && window.webkit.messageHandlers);
const isIOS = /iphone|ipad|ipod/i.test(window.navigator.userAgent || "");
dom.statusBarPlaceholder.classList.toggle("is-visible", hasAppBridge && isIOS);
}
function handleBack() {
if (window.history.length > 1) {
window.history.back();
return;
}
window.location.href = "/";
}
function handleLanguageClick() {
window.location.href = "/language";
}
function handleSearchInput(event) {
state.query = event.target.value;
dom.clearButton.hidden = !state.query;
state.showSearchResults = false;
state.hasSearched = false;
render();
}
function clearSearch() {
state.query = "";
state.searchResults = [];
state.showSearchResults = false;
state.hasSearched = false;
dom.searchInput.value = "";
dom.clearButton.hidden = true;
render();
}
async function triggerSearchNow() {
if (state.searching) {
return;
}
const query = state.query.trim();
if (!query) {
state.showSearchResults = false;
state.hasSearched = false;
render();
return;
}
state.searching = true;
state.showSearchResults = true;
state.hasSearched = true;
state.searchResults = [];
render();
try {
const payload = await searchUser(query);
state.searchResults = payload ? [mergeWithInvitationStatus(payload.userProfile)] : [];
} catch (error) {
state.searchResults = [];
showToast(extractErrorMessage(error), {
title: TEXT.errorTitle,
type: "error",
});
} finally {
state.searching = false;
render();
}
}
async function loadInvitedList() {
try {
const response = await request("GET", ENDPOINTS.invitedList);
state.invitedList = response && response.status && Array.isArray(response.body) ? response.body : [];
state.searchResults = state.searchResults.map(function (item) {
return mergeWithInvitationStatus(item.userProfile || {});
});
render();
} catch (error) {
state.invitedList = [];
render();
}
}
async function searchUser(query) {
try {
const response = await request(
"GET",
"/user/user-profile/open-search?account=" + encodeURIComponent(query)
);
if (response && response.status && response.body) {
return { userProfile: response.body };
}
} catch (error) {
if (!/^\d+$/.test(query)) {
throw error;
}
}
if (!/^\d+$/.test(query)) {
return null;
}
const fallbackResponse = await request(
"GET",
"/user/user-profile?userId=" + encodeURIComponent(query)
);
if (fallbackResponse && fallbackResponse.status && fallbackResponse.body) {
return { userProfile: fallbackResponse.body };
}
return null;
}
function mergeWithInvitationStatus(userProfile) {
const matchedInvitation = state.invitedList.find(function (item) {
return (
item &&
item.userProfile &&
userProfile &&
item.userProfile.id === userProfile.id
);
});
return Object.assign({}, matchedInvitation || {}, {
userProfile: userProfile || {},
});
}
async function inviteUser(item) {
if (!item || !item.userProfile || !item.userProfile.id) {
return;
}
try {
const response = await request("POST", ENDPOINTS.invite, {
body: {
inviteUserId: item.userProfile.id,
},
});
if (response && response.status) {
showToast(TEXT.invitationSubmitted, {
title: TEXT.successTitle,
type: "success",
});
await loadInvitedList();
}
} catch (error) {
showToast(extractErrorMessage(error, TEXT.somethingWentWrong), {
title: TEXT.errorTitle,
type: "error",
});
}
}
async function cancelInvitation(invitationId) {
try {
const response = await request("POST", ENDPOINTS.cancelPrefix + invitationId);
if (response && response.status) {
showToast(TEXT.applicationCancelled, {
title: TEXT.informationTitle,
type: "info",
});
await loadInvitedList();
}
} catch (error) {
showToast(extractErrorMessage(error, TEXT.cancellationFailed), {
title: TEXT.errorTitle,
type: "error",
});
}
}
function render() {
renderSearchButton();
renderList();
}
function renderSearchButton() {
dom.searchButton.disabled = !state.query.trim() || state.searching;
}
function renderList() {
const currentList = state.showSearchResults ? state.searchResults : state.invitedList;
dom.resultList.innerHTML = "";
currentList.forEach(function (item) {
dom.resultList.appendChild(createUserCard(item, state.showSearchResults ? "search" : "invited"));
});
if (currentList.length > 0 || (state.searching && state.showSearchResults)) {
dom.noData.hidden = true;
return;
}
dom.noData.hidden = false;
dom.noData.textContent =
state.showSearchResults && state.hasSearched ? TEXT.noUsersFound : TEXT.idlePrompt;
}
function createUserCard(item, mode) {
const fragment = dom.userCardTemplate.content.cloneNode(true);
const statusBadge = fragment.querySelector(".status-badge");
const avatarImage = fragment.querySelector(".avatar-image");
const nickname = fragment.querySelector(".nickname");
const account = fragment.querySelector(".account");
const pendingActions = fragment.querySelector(".pending-actions");
const userProfile = item.userProfile || {};
avatarImage.src = userProfile.userAvatar || DEFAULT_AVATAR;
avatarImage.alt = userProfile.userNickname || "";
avatarImage.onerror = handleAvatarError;
nickname.textContent = userProfile.userNickname || "";
account.textContent = TEXT.userIdPrefix + " " + (userProfile.account || userProfile.id || "");
if (item.status === 0) {
statusBadge.innerHTML =
'<div style="background-image: linear-gradient(112deg, #759cff 5.66%, #3d73ff 42.49%); padding: 4px 8px; border-radius: 0 12px; color: white; font-weight: 600">' +
TEXT.pending +
"</div>";
} else if (item.status === 1) {
statusBadge.innerHTML =
'<div style="background-image: linear-gradient(112deg, #75ff98 5.66%, #3dff54 42.49%); padding: 4px 8px; border-radius: 0 12px; color: white; font-weight: 600">' +
TEXT.success +
"</div>";
}
if (mode === "search") {
const inviteButton = document.createElement("button");
inviteButton.type = "button";
inviteButton.className = "result-action-btn";
inviteButton.textContent = TEXT.invite;
inviteButton.disabled = item.status === 0 || item.status === 1;
inviteButton.addEventListener("click", function (event) {
event.stopPropagation();
inviteUser(item);
});
pendingActions.appendChild(inviteButton);
} else if (item.status === 0) {
const cancelButton = document.createElement("div");
cancelButton.textContent = TEXT.cancel;
cancelButton.style.padding = "4px 8px";
cancelButton.style.borderRadius = "32px";
cancelButton.style.backgroundColor = "red";
cancelButton.style.color = "white";
cancelButton.style.fontWeight = "600";
cancelButton.style.zIndex = "5";
cancelButton.addEventListener("click", function (event) {
event.stopPropagation();
cancelInvitation(item.id);
});
pendingActions.appendChild(cancelButton);
}
return fragment;
}
function handleAvatarError(event) {
event.target.onerror = null;
event.target.src = DEFAULT_AVATAR;
}
function extractErrorMessage(error, fallback) {
return (
(error &&
error.response &&
(error.response.errorMsg || error.response.message || error.response.error)) ||
(error && error.message) ||
fallback ||
TEXT.somethingWentWrong
);
}
function showToast(message, options) {
const config = Object.assign(
{
title: TEXT.informationTitle,
type: "info",
confirmText: TEXT.ok,
},
options || {}
);
const iconMap = {
error: "✕",
warning: "⚠",
success: "✓",
info: "i",
simple: "",
};
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML =
'<div class="modal modal-enter modal-' +
config.type +
'">' +
(config.type === "simple"
? ""
: '<div class="modal-icon"><div class="icon-circle icon-circle-' +
config.type +
'"><span class="icon-symbol icon-symbol-' +
config.type +
'">' +
iconMap[config.type] +
"</span></div></div>") +
(config.type === "simple" || !config.title
? ""
: '<div class="modal-title">' + escapeHtml(config.title) + "</div>") +
'<div class="modal-message">' +
escapeHtml(message) +
"</div>" +
'<div class="modal-actions"><button class="modal-btn modal-btn-' +
config.type +
'">' +
escapeHtml(config.confirmText) +
"</button></div>" +
"</div>";
const modal = overlay.querySelector(".modal");
const closeButton = overlay.querySelector(".modal-btn");
const close = function () {
modal.classList.remove("modal-enter");
modal.classList.add("modal-leave");
window.setTimeout(function () {
overlay.remove();
}, 200);
};
closeButton.addEventListener("click", close);
overlay.addEventListener("click", function (event) {
if (event.target === overlay) {
close();
}
});
document.body.appendChild(overlay);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
async function request(method, path, options) {
const config = options || {};
const headers = buildRequestHeaders(Boolean(config.body), config.headers || {});
const response = await fetch(API_BASE + path, {
method: method,
credentials: "include",
headers: headers,
body: config.body ? JSON.stringify(config.body) : undefined,
});
const text = await response.text();
let payload = {};
if (text) {
try {
payload = JSON.parse(text);
} catch (error) {
payload = {
status: response.ok,
body: text,
};
}
}
if (!response.ok || payload.status === false) {
const requestError = new Error(
payload.errorMsg || payload.message || response.statusText || TEXT.somethingWentWrong
);
requestError.response = payload;
throw requestError;
}
return payload;
}
function buildRequestHeaders(hasBody, overrideHeaders) {
const discovered = discoverAppHeaders();
const headers = Object.assign({}, overrideHeaders || {});
if (hasBody) {
headers["Content-Type"] = headers["Content-Type"] || "application/json";
}
headers.Accept = headers.Accept || "application/json, text/plain, */*";
if (discovered.authorization) {
headers.Authorization = headers.Authorization || discovered.authorization;
}
if (discovered.reqLang) {
headers["Req-Lang"] = headers["Req-Lang"] || discovered.reqLang;
}
if (discovered.appVersion) {
headers["App-Version"] = headers["App-Version"] || discovered.appVersion;
}
if (discovered.buildVersion) {
headers["Build-Version"] = headers["Build-Version"] || discovered.buildVersion;
}
if (discovered.appChannel) {
headers["App-Channel"] = headers["App-Channel"] || discovered.appChannel;
}
if (discovered.reqImei) {
headers["Req-Imei"] = headers["Req-Imei"] || discovered.reqImei;
}
if (discovered.sysOrigin) {
headers["Sys-Origin"] = headers["Sys-Origin"] || discovered.sysOrigin;
}
if (discovered.sysOriginChild) {
headers["Sys-Origin-Child"] =
headers["Sys-Origin-Child"] || discovered.sysOriginChild;
}
return headers;
}
function discoverAppHeaders() {
const query = new URLSearchParams(window.location.search);
const collected = [];
collectCandidate(collected, window.__APP_HEADERS__);
collectCandidate(collected, window.__HEADER_INFO__);
collectCandidate(collected, window.headerInfo);
collectCandidate(collected, parseHeaderObjectFromQuery(query));
collectFromStorage(collected, window.localStorage);
collectFromStorage(collected, window.sessionStorage);
return collected.reduce(function (result, candidate) {
return Object.assign(result, candidate);
}, {
reqLang: query.get("lang") || document.documentElement.lang || "en",
});
}
function parseHeaderObjectFromQuery(query) {
const candidate = {};
mapHeaderValue(candidate, "authorization", query.get("authorization"));
mapHeaderValue(candidate, "authorization", query.get("token"));
mapHeaderValue(candidate, "reqLang", query.get("lang"));
mapHeaderValue(candidate, "appVersion", query.get("appVersion"));
mapHeaderValue(candidate, "buildVersion", query.get("buildVersion"));
mapHeaderValue(candidate, "appChannel", query.get("appChannel"));
mapHeaderValue(candidate, "reqImei", query.get("reqImei"));
mapHeaderValue(candidate, "sysOrigin", query.get("sysOrigin"));
mapHeaderValue(candidate, "sysOriginChild", query.get("sysOriginChild"));
return candidate;
}
function collectFromStorage(collected, storage) {
if (!storage) {
return;
}
const preferredKeys = [
"authorization",
"Authorization",
"token",
"accessToken",
"headerInfo",
"headersFromApp",
"appHeaders",
"persist:root",
"user",
"userInfo",
];
preferredKeys.forEach(function (key) {
try {
collectCandidate(collected, storage.getItem(key));
} catch (error) {
return;
}
});
for (let index = 0; index < storage.length; index += 1) {
try {
const key = storage.key(index);
collectCandidate(collected, storage.getItem(key));
} catch (error) {
return;
}
}
}
function collectCandidate(collected, value) {
const normalized = normalizeHeaderCandidate(value, 0);
if (normalized && Object.keys(normalized).length > 0) {
collected.push(normalized);
}
}
function normalizeHeaderCandidate(value, depth) {
if (depth > 4 || value == null) {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed[0] === "{" || trimmed[0] === "[") {
try {
return normalizeHeaderCandidate(JSON.parse(trimmed), depth + 1);
} catch (error) {
return null;
}
}
if (/bearer\\s+/i.test(trimmed)) {
return {
authorization: trimmed,
};
}
return null;
}
if (Array.isArray(value)) {
return value.reduce(function (result, entry) {
const normalized = normalizeHeaderCandidate(entry, depth + 1);
return normalized ? Object.assign(result, normalized) : result;
}, {});
}
if (typeof value !== "object") {
return null;
}
const normalized = {};
const aliasMap = {
authorization: ["authorization", "Authorization", "token", "accessToken"],
reqLang: ["reqLang", "Req-Lang", "lang", "language"],
appVersion: ["appVersion", "App-Version"],
buildVersion: ["buildVersion", "Build-Version"],
appChannel: ["appChannel", "App-Channel"],
reqImei: ["reqImei", "Req-Imei"],
sysOrigin: ["sysOrigin", "Sys-Origin"],
sysOriginChild: ["sysOriginChild", "Sys-Origin-Child"],
};
Object.keys(aliasMap).forEach(function (targetKey) {
aliasMap[targetKey].forEach(function (sourceKey) {
mapHeaderValue(normalized, targetKey, value[sourceKey]);
});
});
["headerInfo", "headers", "auth", "data", "state"].forEach(function (nestedKey) {
const nestedValue = normalizeHeaderCandidate(value[nestedKey], depth + 1);
if (nestedValue) {
Object.assign(normalized, nestedValue);
}
});
Object.keys(value).forEach(function (key) {
if (
key === "headerInfo" ||
key === "headers" ||
key === "auth" ||
key === "data" ||
key === "state"
) {
return;
}
if (typeof value[key] === "object") {
const nestedValue = normalizeHeaderCandidate(value[key], depth + 1);
if (nestedValue) {
Object.assign(normalized, nestedValue);
}
}
});
return normalized;
}
function mapHeaderValue(target, key, value) {
if (value == null || value === "" || target[key]) {
return;
}
target[key] = String(value);
}
})();

View File

@ -0,0 +1,119 @@
<!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 User To Become Recharge Agent</title>
<link rel="stylesheet" href="./yumi-invite-recharge-agency.css" />
</head>
<body>
<div class="fullPage" id="pageRoot">
<div class="status-bar-placeholder" id="statusBarPlaceholder"></div>
<div class="header-container">
<div class="side-area">
<button class="back-btn" id="backButton" type="button">
<img class="flip-img" id="backButtonIcon" src="" alt="" style="width: 100%; aspect-ratio: 1 / 1; display: block" />
</button>
</div>
<h1 class="header-title" id="pageTitle" style="color: black">Invite User To Become Recharge Agent</h1>
<div class="actions-area">
<div class="language-entry" id="languageEntry">
<div class="language-name" id="languageName">English</div>
</div>
</div>
</div>
<div style="padding: 16px; position: relative">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
<div
class="gradient-border-wrapper"
style="
flex: 1;
min-width: 0;
border-radius: 4px;
--gradient-border-width: 1.5px;
--gradient-direction: to bottom;
--gradient-start-color: #ffc4c4;
--gradient-end-color: #fff6a6;
--gradient-background: linear-gradient(120deg, rgba(255, 216, 0, 0.1) 39.13%, rgba(255, 149, 0, 0.1) 119.28%);
"
>
<div class="gradient-border-content">
<div style="padding: 0 8px; display: flex; align-items: center; height: max-content">
<input
id="searchInput"
type="text"
placeholder="Enter User ID"
autocomplete="off"
style="
width: 100%;
color: rgba(0, 0, 0, 0.4);
font-weight: 600;
outline: none;
border: none;
background-color: transparent;
padding: 8px;
"
/>
<button
id="clearButton"
type="button"
hidden
style="background: none; border: none; color: #9ca3af; cursor: pointer; padding: 0 4px"
>
×
</button>
</div>
</div>
</div>
<button style="font-weight: 600" class="invite-btn" id="searchButton" type="button" disabled>Search</button>
</div>
<div id="resultList" style="margin: 16px 0; display: flex; flex-direction: column; gap: 12px"></div>
<div id="noData" class="no-data">please input user id</div>
</div>
</div>
<template id="userCardTemplate">
<div
class="gradient-border-wrapper user-card"
style="
position: relative;
z-index: 0;
border-radius: 20px;
--gradient-border-width: 1.5px;
--gradient-direction: to bottom;
--gradient-start-color: #ffc4c4;
--gradient-end-color: #fff6a6;
--gradient-background: linear-gradient(120deg, rgba(255, 216, 0, 0.1) 39.13%, rgba(255, 149, 0, 0.1) 119.28%);
"
>
<div class="gradient-border-content">
<div style="padding: 10px; transition: all 0.2s">
<div class="status-badge" style="position: absolute; z-index: 3; top: -2px; right: -2px"></div>
<div style="font-weight: 600">Information:</div>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 4px">
<div style="flex: 1; min-width: 0; align-self: stretch; display: flex; align-items: center; gap: 4px">
<div style="width: 3em; display: flex; align-items: center; justify-content: center">
<img class="avatar-image" src="" alt="" style="width: 100%; aspect-ratio: 1/1; object-fit: cover; border-radius: 50%" />
</div>
<div style="flex: 1; min-width: 0; align-self: stretch">
<div class="nickname" style="margin-bottom: 4px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"></div>
<div class="account" style="font-size: 0.9em; color: rgba(0, 0, 0, 0.4); font-weight: 500"></div>
</div>
</div>
<div style="width: auto; min-width: 0; align-self: stretch; display: flex; justify-content: center; align-items: center">
<div class="pending-actions"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script src="./yumi-invite-recharge-agency.js"></script>
</body>
</html>

View File

@ -0,0 +1,456 @@
html {
-webkit-text-size-adjust: 100%;
-webkit-overflow-scrolling: touch;
}
body {
margin: 0;
min-height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
}
* {
box-sizing: border-box;
}
button,
input {
font: inherit;
}
[hidden] {
display: none !important;
}
.fullPage {
width: 100vw;
min-height: 100vh;
background-color: #fff;
background-image: url("../assets/secondBg-CSqvFWr0-1776148661447.png");
background-size: 100% auto;
background-repeat: no-repeat;
position: relative;
color: rgba(0, 0, 0, 0.8);
}
.fullPage input::placeholder {
font-weight: 700;
color: rgba(0, 0, 0, 0.4);
}
.invite-btn {
padding: 4px 12px;
border-radius: 32px;
background: linear-gradient(269deg, #feb219 7.04%, #ff9326 96.78%);
color: #fff;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.invite-btn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.no-data {
text-align: center;
padding: 40px 20px;
color: #666;
}
.user-card {
cursor: default;
}
.result-action-btn {
padding: 4px 10px;
border-radius: 32px;
background: linear-gradient(269deg, #feb219 7.04%, #ff9326 96.78%);
color: #fff;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.result-action-btn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.status-bar-placeholder {
display: none;
height: 30px;
}
.status-bar-placeholder.is-visible {
display: block;
}
.header-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
height: 60px;
font-size: 16px;
}
.side-area,
.actions-area {
min-width: 1.5em;
display: flex;
align-items: center;
}
.actions-area {
justify-content: flex-end;
gap: 8px;
}
.header-title {
flex: 1;
margin: 0;
font-size: 1.2em;
font-weight: 600;
text-align: center;
letter-spacing: 0.3px;
}
.back-btn,
.help-btn {
width: 1.5em;
border: none;
background: transparent;
transition: all 0.2s ease;
padding: 0;
}
.back-btn:active,
.help-btn:active {
transform: scale(0.9);
}
.language-entry {
display: flex;
align-items: center;
color: #f59e0b !important;
cursor: pointer;
}
.language-name {
font-weight: 700;
}
.gradient-border-wrapper {
position: relative;
}
.gradient-border-wrapper::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: var(--gradient-border-width, 1.5px);
background: linear-gradient(
var(--gradient-direction, to bottom),
var(--gradient-start-color, #ffc4c4),
var(--gradient-end-color, #fff6a6)
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.gradient-border-content {
position: relative;
border-radius: inherit;
width: 100%;
height: 100%;
background: var(
--gradient-background,
linear-gradient(120deg, rgba(255, 216, 0, 0.1) 39.13%, rgba(255, 149, 0, 0.1) 119.28%)
);
}
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 20px;
}
.modal {
background: #fff;
border-radius: 16px;
box-shadow:
0 20px 25px rgba(0, 0, 0, 0.1),
0 10px 10px rgba(0, 0, 0, 0.04);
width: 100%;
max-width: 320px;
padding: 24px 20px 20px;
text-align: center;
position: relative;
}
.modal-icon {
margin-bottom: 16px;
display: flex;
justify-content: center;
}
.icon-circle {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
animation: iconPulse 0.6s ease-out;
border: 3px solid;
}
.icon-symbol {
font-size: 24px;
font-weight: 700;
line-height: 1;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin-bottom: 12px;
line-height: 1.4;
}
.modal-message {
font-size: 14px;
color: #6b7280;
line-height: 1.5;
margin-bottom: 20px;
word-wrap: break-word;
}
.modal-actions {
display: flex;
justify-content: center;
}
.modal-btn {
border: none;
border-radius: 8px;
padding: 10px 32px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
color: #fff;
}
.modal-btn:active {
transform: scale(0.98);
}
.modal-error .icon-circle-error {
background-color: #fee2e2;
border-color: #fecaca;
}
.modal-error .icon-symbol-error {
color: #dc2626;
}
.modal-error .modal-btn-error {
background-color: #dc2626;
}
.modal-warning .icon-circle-warning {
background-color: #fef3c7;
border-color: #fde68a;
}
.modal-warning .icon-symbol-warning {
color: #d97706;
}
.modal-warning .modal-btn-warning {
background-color: #d97706;
}
.modal-success .icon-circle-success {
background-color: #d1fae5;
border-color: #a7f3d0;
}
.modal-success .icon-symbol-success {
color: #059669;
}
.modal-success .modal-btn-success {
background-color: #059669;
}
.modal-info .icon-circle-info {
background-color: #dbeafe;
border-color: #bfdbfe;
}
.modal-info .icon-symbol-info {
color: #2563eb;
}
.modal-info .modal-btn-info {
background-color: #2563eb;
}
.modal-simple .modal-message {
font-size: 15px;
color: #374151;
margin-bottom: 24px;
}
.modal-simple .modal-btn {
background-color: #2563eb;
}
.modal-enter {
animation: modalEnter 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-leave {
animation: modalLeave 0.2s ease-in;
}
[dir="rtl"] .flip-img {
transform: scaleX(-1);
}
[dir="rtl"] .avatar {
margin-right: 0;
margin-left: 12px;
}
[dir="rtl"] .user-info {
text-align: right;
}
[dir="rtl"] .language-entry,
[dir="rtl"] .header-title {
direction: rtl;
}
@keyframes modalEnter {
0% {
opacity: 0;
transform: scale(0.7) translateY(-10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modalLeave {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
100% {
opacity: 0;
transform: scale(0.9) translateY(-5px);
}
}
@keyframes iconPulse {
0% {
transform: scale(0.8);
opacity: 0.5;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media screen and (max-width: 360px) {
.fullPage {
font-size: 10px;
}
.header-container {
font-size: 10px;
}
}
@media screen and (min-width: 360px) {
.fullPage {
font-size: 12px;
}
.header-container {
font-size: 16px;
}
}
@media screen and (min-width: 768px) {
.fullPage {
font-size: 24px;
}
.header-container {
font-size: 24px;
}
}
@media screen and (min-width: 1024px) {
.fullPage {
font-size: 32px;
}
.header-container {
font-size: 32px;
}
}
@media (max-width: 480px) {
.modal {
max-width: 280px;
padding: 20px 16px 16px;
}
.icon-circle {
width: 48px;
height: 48px;
}
.icon-symbol {
font-size: 20px;
}
.modal-title {
font-size: 16px;
}
.modal-message {
font-size: 13px;
}
}

View File

@ -0,0 +1,699 @@
(function () {
const API_BASE = "https://jvapi.haiyihy.com";
const BACK_ICON =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAGXSURBVHgB7dvBTcNAFIThB5xTQFwADVBBmqEnUgZQAAXAjRqgACgAj2wfECbJZXZnrPmkVRT5lF9ODn4vVRERERERETEaxnNb8cd+PA/jeZ3PY02xqG7KA+Ic6/eds5vfPxfRdelb4uxXrt0VmXqgU3Hgo8iUA52LA9SvF1yVpkviHOdDpRhIJg6oBZKKA0qB5OKASiDJOKAQSDYO9A4kHQd6BpKPA70CWcSBHoFs4kDrQFZxoGUguzjQKpBlHGgRyDYOsANZxwFmIPs4wAq0iTjACvRUG4gDjEeumDRsIg4wAn3VhjDmYt81jWOGf64vo5q3MsAaHOLDH2oa7q2xicQKhLvopTYQiTl63kQk9mzePlKL5QXrSK22O2wjtVx/sYzUej/ILlKPBSqrSL02zGwi9VzBs4jUe0dRPpLCEqd0JJUtV9lISmvAkpHU9qTlIikukktFUt20vzQSHu++F5HqGvDi3PgIgQ5FpL5p/zme+/l1za7IHP6rcSoSfdPeIRCsRcIPtM18rSUMJoeKiIiIiIjw9QNKh3OJhFQBoAAAAABJRU5ErkJggg==";
const DEFAULT_AVATAR = "../assets/defaultAvatar-CdxWBK1k-1776148661448.png";
const ENDPOINTS = {
invitedList: "/team/bd/invite-recharge-agent-message-own",
invite: "/team/bd/invite-recharge-agent",
cancelPrefix: "/team/bd/invite-recharge-agent-cancel/",
};
const TEXT = {
pageTitle: "Invite User To Become Recharge Agent",
language: "English",
placeholder: "Enter User ID",
search: "Search",
invite: "Invite",
idlePrompt: "please input user id",
noUsersFound: "No users found",
information: "Information:",
userIdPrefix: "User ID:",
pending: "Pending",
success: "Success",
cancel: "Cancel",
invitationSubmitted: "Invitation submitted",
applicationCancelled: "Application cancelled",
somethingWentWrong: "Something went wrong",
cancellationFailed: "Cancellation failed, try again",
informationTitle: "Information",
successTitle: "Success",
errorTitle: "Error",
ok: "OK",
};
const state = {
query: "",
invitedList: [],
searchResults: [],
showSearchResults: false,
searching: false,
hasSearched: false,
};
const dom = {
statusBarPlaceholder: document.getElementById("statusBarPlaceholder"),
backButton: document.getElementById("backButton"),
backButtonIcon: document.getElementById("backButtonIcon"),
pageTitle: document.getElementById("pageTitle"),
languageEntry: document.getElementById("languageEntry"),
languageName: document.getElementById("languageName"),
searchInput: document.getElementById("searchInput"),
clearButton: document.getElementById("clearButton"),
searchButton: document.getElementById("searchButton"),
resultList: document.getElementById("resultList"),
noData: document.getElementById("noData"),
userCardTemplate: document.getElementById("userCardTemplate"),
};
init();
function init() {
document.title = TEXT.pageTitle;
document.documentElement.lang = "en";
document.documentElement.dir = resolveDocumentDirection();
dom.backButtonIcon.src = BACK_ICON;
dom.pageTitle.textContent = TEXT.pageTitle;
dom.languageName.textContent = TEXT.language;
dom.searchInput.placeholder = TEXT.placeholder;
dom.searchButton.textContent = TEXT.search;
dom.noData.textContent = TEXT.idlePrompt;
syncStatusBarPlaceholder();
bindEvents();
render();
loadInvitedList();
}
function bindEvents() {
dom.backButton.addEventListener("click", handleBack);
dom.languageEntry.addEventListener("click", handleLanguageClick);
dom.searchInput.addEventListener("input", handleSearchInput);
dom.clearButton.addEventListener("click", clearSearch);
dom.searchButton.addEventListener("click", triggerSearchNow);
}
function resolveDocumentDirection() {
const lang = (new URLSearchParams(window.location.search).get("lang") || "").toLowerCase();
return lang.startsWith("ar") ? "rtl" : "ltr";
}
function syncStatusBarPlaceholder() {
const hasAppBridge =
Boolean(window.app) ||
Boolean(window.FlutterPageControl) ||
Boolean(window.webkit && window.webkit.messageHandlers);
const isIOS = /iphone|ipad|ipod/i.test(window.navigator.userAgent || "");
dom.statusBarPlaceholder.classList.toggle("is-visible", hasAppBridge && isIOS);
}
function handleBack() {
if (window.history.length > 1) {
window.history.back();
return;
}
window.location.href = "/";
}
function handleLanguageClick() {
window.location.href = "/language";
}
function handleSearchInput(event) {
state.query = event.target.value;
dom.clearButton.hidden = !state.query;
state.showSearchResults = false;
state.hasSearched = false;
render();
}
function clearSearch() {
state.query = "";
state.searchResults = [];
state.showSearchResults = false;
state.hasSearched = false;
dom.searchInput.value = "";
dom.clearButton.hidden = true;
render();
}
async function triggerSearchNow() {
if (state.searching) {
return;
}
const query = state.query.trim();
if (!query) {
state.showSearchResults = false;
state.hasSearched = false;
render();
return;
}
state.searching = true;
state.showSearchResults = true;
state.hasSearched = true;
state.searchResults = [];
render();
try {
const payload = await searchUser(query);
state.searchResults = payload ? [mergeWithInvitationStatus(payload.userProfile)] : [];
} catch (error) {
state.searchResults = [];
showToast(extractErrorMessage(error), {
title: TEXT.errorTitle,
type: "error",
});
} finally {
state.searching = false;
render();
}
}
async function loadInvitedList() {
try {
const response = await request("GET", ENDPOINTS.invitedList);
state.invitedList = response && response.status && Array.isArray(response.body) ? response.body : [];
state.searchResults = state.searchResults.map(function (item) {
return mergeWithInvitationStatus(item.userProfile || {});
});
render();
} catch (error) {
state.invitedList = [];
render();
}
}
async function searchUser(query) {
try {
const response = await request(
"GET",
"/user/user-profile/open-search?account=" + encodeURIComponent(query)
);
if (response && response.status && response.body) {
return { userProfile: response.body };
}
} catch (error) {
if (!/^\d+$/.test(query)) {
throw error;
}
}
if (!/^\d+$/.test(query)) {
return null;
}
const fallbackResponse = await request(
"GET",
"/user/user-profile?userId=" + encodeURIComponent(query)
);
if (fallbackResponse && fallbackResponse.status && fallbackResponse.body) {
return { userProfile: fallbackResponse.body };
}
return null;
}
function mergeWithInvitationStatus(userProfile) {
const matchedInvitation = state.invitedList.find(function (item) {
return (
item &&
item.userProfile &&
userProfile &&
item.userProfile.id === userProfile.id
);
});
return Object.assign({}, matchedInvitation || {}, {
userProfile: userProfile || {},
});
}
async function inviteUser(item) {
if (!item || !item.userProfile || !item.userProfile.id) {
return;
}
try {
const response = await request("POST", ENDPOINTS.invite, {
body: {
inviteUserId: item.userProfile.id,
},
});
if (response && response.status) {
showToast(TEXT.invitationSubmitted, {
title: TEXT.successTitle,
type: "success",
});
await loadInvitedList();
}
} catch (error) {
showToast(extractErrorMessage(error, TEXT.somethingWentWrong), {
title: TEXT.errorTitle,
type: "error",
});
}
}
async function cancelInvitation(invitationId) {
try {
const response = await request("POST", ENDPOINTS.cancelPrefix + invitationId);
if (response && response.status) {
showToast(TEXT.applicationCancelled, {
title: TEXT.informationTitle,
type: "info",
});
await loadInvitedList();
}
} catch (error) {
showToast(extractErrorMessage(error, TEXT.cancellationFailed), {
title: TEXT.errorTitle,
type: "error",
});
}
}
function render() {
renderSearchButton();
renderList();
}
function renderSearchButton() {
dom.searchButton.disabled = !state.query.trim() || state.searching;
}
function renderList() {
const currentList = state.showSearchResults ? state.searchResults : state.invitedList;
dom.resultList.innerHTML = "";
currentList.forEach(function (item) {
dom.resultList.appendChild(createUserCard(item, state.showSearchResults ? "search" : "invited"));
});
if (currentList.length > 0 || (state.searching && state.showSearchResults)) {
dom.noData.hidden = true;
return;
}
dom.noData.hidden = false;
dom.noData.textContent =
state.showSearchResults && state.hasSearched ? TEXT.noUsersFound : TEXT.idlePrompt;
}
function createUserCard(item, mode) {
const fragment = dom.userCardTemplate.content.cloneNode(true);
const statusBadge = fragment.querySelector(".status-badge");
const avatarImage = fragment.querySelector(".avatar-image");
const nickname = fragment.querySelector(".nickname");
const account = fragment.querySelector(".account");
const pendingActions = fragment.querySelector(".pending-actions");
const userProfile = item.userProfile || {};
avatarImage.src = userProfile.userAvatar || DEFAULT_AVATAR;
avatarImage.alt = userProfile.userNickname || "";
avatarImage.onerror = handleAvatarError;
nickname.textContent = userProfile.userNickname || "";
account.textContent = TEXT.userIdPrefix + " " + (userProfile.account || userProfile.id || "");
if (item.status === 0) {
statusBadge.innerHTML =
'<div style="background-image: linear-gradient(112deg, #759cff 5.66%, #3d73ff 42.49%); padding: 4px 8px; border-radius: 0 12px; color: white; font-weight: 600">' +
TEXT.pending +
"</div>";
} else if (item.status === 1) {
statusBadge.innerHTML =
'<div style="background-image: linear-gradient(112deg, #75ff98 5.66%, #3dff54 42.49%); padding: 4px 8px; border-radius: 0 12px; color: white; font-weight: 600">' +
TEXT.success +
"</div>";
}
if (mode === "search") {
const inviteButton = document.createElement("button");
inviteButton.type = "button";
inviteButton.className = "result-action-btn";
inviteButton.textContent = TEXT.invite;
inviteButton.disabled = item.status === 0 || item.status === 1;
inviteButton.addEventListener("click", function (event) {
event.stopPropagation();
inviteUser(item);
});
pendingActions.appendChild(inviteButton);
} else if (item.status === 0) {
const cancelButton = document.createElement("div");
cancelButton.textContent = TEXT.cancel;
cancelButton.style.padding = "4px 8px";
cancelButton.style.borderRadius = "32px";
cancelButton.style.backgroundColor = "red";
cancelButton.style.color = "white";
cancelButton.style.fontWeight = "600";
cancelButton.style.zIndex = "5";
cancelButton.addEventListener("click", function (event) {
event.stopPropagation();
cancelInvitation(item.id);
});
pendingActions.appendChild(cancelButton);
}
return fragment;
}
function handleAvatarError(event) {
event.target.onerror = null;
event.target.src = DEFAULT_AVATAR;
}
function extractErrorMessage(error, fallback) {
return (
(error &&
error.response &&
(error.response.errorMsg || error.response.message || error.response.error)) ||
(error && error.message) ||
fallback ||
TEXT.somethingWentWrong
);
}
function showToast(message, options) {
const config = Object.assign(
{
title: TEXT.informationTitle,
type: "info",
confirmText: TEXT.ok,
},
options || {}
);
const iconMap = {
error: "✕",
warning: "⚠",
success: "✓",
info: "i",
simple: "",
};
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML =
'<div class="modal modal-enter modal-' +
config.type +
'">' +
(config.type === "simple"
? ""
: '<div class="modal-icon"><div class="icon-circle icon-circle-' +
config.type +
'"><span class="icon-symbol icon-symbol-' +
config.type +
'">' +
iconMap[config.type] +
"</span></div></div>") +
(config.type === "simple" || !config.title
? ""
: '<div class="modal-title">' + escapeHtml(config.title) + "</div>") +
'<div class="modal-message">' +
escapeHtml(message) +
"</div>" +
'<div class="modal-actions"><button class="modal-btn modal-btn-' +
config.type +
'">' +
escapeHtml(config.confirmText) +
"</button></div>" +
"</div>";
const modal = overlay.querySelector(".modal");
const closeButton = overlay.querySelector(".modal-btn");
const close = function () {
modal.classList.remove("modal-enter");
modal.classList.add("modal-leave");
window.setTimeout(function () {
overlay.remove();
}, 200);
};
closeButton.addEventListener("click", close);
overlay.addEventListener("click", function (event) {
if (event.target === overlay) {
close();
}
});
document.body.appendChild(overlay);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
async function request(method, path, options) {
const config = options || {};
const headers = buildRequestHeaders(Boolean(config.body), config.headers || {});
const response = await fetch(API_BASE + path, {
method: method,
credentials: "include",
headers: headers,
body: config.body ? JSON.stringify(config.body) : undefined,
});
const text = await response.text();
let payload = {};
if (text) {
try {
payload = JSON.parse(text);
} catch (error) {
payload = {
status: response.ok,
body: text,
};
}
}
if (!response.ok || payload.status === false) {
const requestError = new Error(
payload.errorMsg || payload.message || response.statusText || TEXT.somethingWentWrong
);
requestError.response = payload;
throw requestError;
}
return payload;
}
function buildRequestHeaders(hasBody, overrideHeaders) {
const discovered = discoverAppHeaders();
const headers = Object.assign({}, overrideHeaders || {});
if (hasBody) {
headers["Content-Type"] = headers["Content-Type"] || "application/json";
}
headers.Accept = headers.Accept || "application/json, text/plain, */*";
if (discovered.authorization) {
headers.Authorization = headers.Authorization || discovered.authorization;
}
if (discovered.reqLang) {
headers["Req-Lang"] = headers["Req-Lang"] || discovered.reqLang;
}
if (discovered.appVersion) {
headers["App-Version"] = headers["App-Version"] || discovered.appVersion;
}
if (discovered.buildVersion) {
headers["Build-Version"] = headers["Build-Version"] || discovered.buildVersion;
}
if (discovered.appChannel) {
headers["App-Channel"] = headers["App-Channel"] || discovered.appChannel;
}
if (discovered.reqImei) {
headers["Req-Imei"] = headers["Req-Imei"] || discovered.reqImei;
}
if (discovered.sysOrigin) {
headers["Sys-Origin"] = headers["Sys-Origin"] || discovered.sysOrigin;
}
if (discovered.sysOriginChild) {
headers["Sys-Origin-Child"] =
headers["Sys-Origin-Child"] || discovered.sysOriginChild;
}
return headers;
}
function discoverAppHeaders() {
const query = new URLSearchParams(window.location.search);
const collected = [];
collectCandidate(collected, window.__APP_HEADERS__);
collectCandidate(collected, window.__HEADER_INFO__);
collectCandidate(collected, window.headerInfo);
collectCandidate(collected, parseHeaderObjectFromQuery(query));
collectFromStorage(collected, window.localStorage);
collectFromStorage(collected, window.sessionStorage);
return collected.reduce(function (result, candidate) {
return Object.assign(result, candidate);
}, {
reqLang: query.get("lang") || document.documentElement.lang || "en",
});
}
function parseHeaderObjectFromQuery(query) {
const candidate = {};
mapHeaderValue(candidate, "authorization", query.get("authorization"));
mapHeaderValue(candidate, "authorization", query.get("token"));
mapHeaderValue(candidate, "reqLang", query.get("lang"));
mapHeaderValue(candidate, "appVersion", query.get("appVersion"));
mapHeaderValue(candidate, "buildVersion", query.get("buildVersion"));
mapHeaderValue(candidate, "appChannel", query.get("appChannel"));
mapHeaderValue(candidate, "reqImei", query.get("reqImei"));
mapHeaderValue(candidate, "sysOrigin", query.get("sysOrigin"));
mapHeaderValue(candidate, "sysOriginChild", query.get("sysOriginChild"));
return candidate;
}
function collectFromStorage(collected, storage) {
if (!storage) {
return;
}
const preferredKeys = [
"authorization",
"Authorization",
"token",
"accessToken",
"headerInfo",
"headersFromApp",
"appHeaders",
"persist:root",
"user",
"userInfo",
];
preferredKeys.forEach(function (key) {
try {
collectCandidate(collected, storage.getItem(key));
} catch (error) {
return;
}
});
for (let index = 0; index < storage.length; index += 1) {
try {
const key = storage.key(index);
collectCandidate(collected, storage.getItem(key));
} catch (error) {
return;
}
}
}
function collectCandidate(collected, value) {
const normalized = normalizeHeaderCandidate(value, 0);
if (normalized && Object.keys(normalized).length > 0) {
collected.push(normalized);
}
}
function normalizeHeaderCandidate(value, depth) {
if (depth > 4 || value == null) {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed[0] === "{" || trimmed[0] === "[") {
try {
return normalizeHeaderCandidate(JSON.parse(trimmed), depth + 1);
} catch (error) {
return null;
}
}
if (/bearer\\s+/i.test(trimmed)) {
return {
authorization: trimmed,
};
}
return null;
}
if (Array.isArray(value)) {
return value.reduce(function (result, entry) {
const normalized = normalizeHeaderCandidate(entry, depth + 1);
return normalized ? Object.assign(result, normalized) : result;
}, {});
}
if (typeof value !== "object") {
return null;
}
const normalized = {};
const aliasMap = {
authorization: ["authorization", "Authorization", "token", "accessToken"],
reqLang: ["reqLang", "Req-Lang", "lang", "language"],
appVersion: ["appVersion", "App-Version"],
buildVersion: ["buildVersion", "Build-Version"],
appChannel: ["appChannel", "App-Channel"],
reqImei: ["reqImei", "Req-Imei"],
sysOrigin: ["sysOrigin", "Sys-Origin"],
sysOriginChild: ["sysOriginChild", "Sys-Origin-Child"],
};
Object.keys(aliasMap).forEach(function (targetKey) {
aliasMap[targetKey].forEach(function (sourceKey) {
mapHeaderValue(normalized, targetKey, value[sourceKey]);
});
});
["headerInfo", "headers", "auth", "data", "state"].forEach(function (nestedKey) {
const nestedValue = normalizeHeaderCandidate(value[nestedKey], depth + 1);
if (nestedValue) {
Object.assign(normalized, nestedValue);
}
});
Object.keys(value).forEach(function (key) {
if (
key === "headerInfo" ||
key === "headers" ||
key === "auth" ||
key === "data" ||
key === "state"
) {
return;
}
if (typeof value[key] === "object") {
const nestedValue = normalizeHeaderCandidate(value[key], depth + 1);
if (nestedValue) {
Object.assign(normalized, nestedValue);
}
}
});
return normalized;
}
function mapHeaderValue(target, key, value) {
if (value == null || value === "" || target[key]) {
return;
}
target[key] = String(value);
}
})();