充值接入

This commit is contained in:
170-carry 2026-05-01 00:30:30 +08:00
parent 6930596c60
commit 56a36f5fc2
23 changed files with 724 additions and 72 deletions

View File

@ -2,7 +2,29 @@ const query = readURLParams();
const storedAppId = window.localStorage.getItem("mifa-pay-application-id") || ""; const storedAppId = window.localStorage.getItem("mifa-pay-application-id") || "";
const API_BASE_URL = "https://jvapi.haiyihy.com"; const API_BASE_URL = "https://jvapi.haiyihy.com";
const LOCAL_API_BASE_URL = "http://127.0.0.1:1100"; const LOCAL_API_BASE_URL = "http://127.0.0.1:1100";
const LOGO_SRC = "./mifapay-logo.png";
const CHANNEL_LOGOS = [
["qris", "./logos/qris.png"],
["dana", "./logos/dana.png"],
["linkaja", "./logos/linkaja.png"],
["ovo", "./logos/ovo.png"],
["shopeepay", "./logos/shopeepay.png"],
["bca", "./logos/bca.png"],
["bni", "./logos/bni.png"],
["permata", "./logos/permata.png"],
["mandiri", "./logos/mandiri.png"],
["bri", "./logos/bri.png"],
["upi", "./logos/upi.png"],
["paytm", "./logos/paytm.png"],
["phonepe", "./logos/phonepe.png"],
["googlepay", "./logos/googlepay.png"],
["bhim", "./logos/bhim.png"],
["mobikwik", "./logos/mobikwik.png"],
["jazzcash", "./logos/jazzcash.png"],
["easypaisa", "./logos/easypaisa.png"],
["vietqr", "./logos/vietqr.png"],
["vnbanktransfer", "./logos/vn-banktransfer.png"],
];
function readURLParams() { function readURLParams() {
const result = new URLSearchParams(window.location.search); const result = new URLSearchParams(window.location.search);
@ -46,8 +68,11 @@ const state = {
regionId: "", regionId: "",
selectedCountryId: "", selectedCountryId: "",
commodityCard: null, commodityCard: null,
loading: true,
unsupportedRegion: false,
selectedGoodsId: "", selectedGoodsId: "",
selectedGoods: null, selectedGoods: null,
selectedChannelCode: "",
pendingChannelCode: "", pendingChannelCode: "",
}; };
@ -55,6 +80,8 @@ const els = {
applicationId: document.getElementById("applicationId"), applicationId: document.getElementById("applicationId"),
productList: document.getElementById("productList"), productList: document.getElementById("productList"),
channelList: document.getElementById("channelList"), channelList: document.getElementById("channelList"),
unsupportedMessage: document.getElementById("unsupportedMessage"),
goRecharge: document.getElementById("goRecharge"),
pageStatus: document.getElementById("pageStatus"), pageStatus: document.getElementById("pageStatus"),
}; };
@ -146,7 +173,14 @@ function currentLanguage() {
return query.get("lang") || document.documentElement.lang || navigator.language || "en"; return query.get("lang") || document.documentElement.lang || navigator.language || "en";
} }
function currentSysOrigin() {
return String(state.application?.appCode || query.get("sysOrigin") || query.get("origin") || "LIKEI")
.trim()
.toUpperCase();
}
function authHeadersFromToken(token) { function authHeadersFromToken(token) {
const sysOrigin = currentSysOrigin();
return { return {
Accept: "application/json, text/plain, */*", Accept: "application/json, text/plain, */*",
Authorization: normalizeAuthorization(token), Authorization: normalizeAuthorization(token),
@ -155,7 +189,7 @@ function authHeadersFromToken(token) {
"req-version": "V2", "req-version": "V2",
"req-zone": Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai", "req-zone": Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai",
"req-app-intel": "version=1.0.0;build=1;model=H5;sysVersion=Web;channel=Web", "req-app-intel": "version=1.0.0;build=1;model=H5;sysVersion=Web;channel=Web",
"req-sys-origin": "origin=ATYOU;originChild=ATYOU", "req-sys-origin": `origin=${sysOrigin};originChild=${sysOrigin}`,
}; };
} }
@ -224,6 +258,23 @@ function extractErrorMessage(error) {
); );
} }
function escapeHTML(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => {
const entities = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
return entities[char];
});
}
function escapeAttribute(value) {
return escapeHTML(value).replace(/`/g, "&#96;");
}
function readStoredUserProfile() { function readStoredUserProfile() {
const raw = window.localStorage.getItem("userProfile"); const raw = window.localStorage.getItem("userProfile");
if (!raw) { if (!raw) {
@ -250,8 +301,16 @@ function currentUserId() {
return String(state.userProfile?.id || "").trim(); return String(state.userProfile?.id || "").trim();
} }
function currentCountryId() { function currentCountry() {
return state.selectedCountryId; return (
state.countries.find((item) => String(item.id) === String(state.selectedCountryId)) ||
null
);
}
function currentPayCountryId() {
const selected = currentCountry();
return String(selected?.payCountryId || selected?.id || state.selectedCountryId || "");
} }
function currentRegionId() { function currentRegionId() {
@ -282,22 +341,230 @@ function getGoodsList() {
return state.commodityCard?.commodity || []; return state.commodityCard?.commodity || [];
} }
function getChannelList() { function getAllChannelList() {
return state.commodityCard?.channels || []; return state.commodityCard?.channels || [];
} }
function toNumber(value, fallback = 0) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function channelSupportsGoods(channel, goods) {
if (!goods) {
return true;
}
const details = channel?.details || {};
const amountUsd = toNumber(goods?.amountUsd || goods?.amount);
const min = toNumber(details.factoryMinLimit, 0);
const max = toNumber(details.factoryMaxLimit, Number.MAX_SAFE_INTEGER);
const daily = toNumber(details.factoryDailyLimit, Number.MAX_SAFE_INTEGER);
return amountUsd >= min && amountUsd <= max && amountUsd <= daily;
}
function getChannelList(goods = state.selectedGoods) {
return getAllChannelList().filter((item) => channelSupportsGoods(item, goods));
}
function goodsHasChannel(goods) {
return getChannelList(goods).length > 0;
}
function hasAvailablePayment() {
return getGoodsList().some((goods) => goodsHasChannel(goods));
}
function setUnsupportedRegion(value) {
state.unsupportedRegion = Boolean(value);
renderUnsupportedState();
}
function renderUnsupportedState() {
const show = !state.loading && state.unsupportedRegion;
els.unsupportedMessage.hidden = !show;
els.unsupportedMessage.className = show
? "unsupported-state show"
: "unsupported-state";
els.productList.hidden = show;
els.channelList.hidden = show;
if (show) {
els.goRecharge.hidden = true;
els.productList.innerHTML = "";
els.channelList.innerHTML = "";
}
}
function getChannelCode(item) { function getChannelCode(item) {
return item?.details?.channelCode || item?.channel?.channelCode || ""; return item?.details?.channelCode || item?.channel?.channelCode || "";
} }
function getChannelName(item) {
return String(
item?.channel?.channelName ||
item?.details?.factoryChannel ||
item?.details?.channelCode ||
item?.channel?.channelCode ||
"Pay"
).trim();
}
function normalizeLogoKey(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
function getChannelLogoCandidates(item) {
const values = [
item?.channel?.channelCode,
item?.details?.channelCode,
item?.details?.factoryChannel,
item?.channel?.channelName,
getChannelName(item),
];
const keys = new Set();
values.filter(Boolean).forEach((value) => {
const text = String(value);
const normalized = normalizeLogoKey(text);
if (normalized) {
keys.add(normalized);
}
text
.split(/[_\s/-]+/)
.map(normalizeLogoKey)
.filter(Boolean)
.forEach((key) => keys.add(key));
});
return Array.from(keys);
}
function getLocalChannelIconURL(item) {
const candidates = getChannelLogoCandidates(item);
const exact = CHANNEL_LOGOS.find(([key]) => candidates.includes(key));
if (exact) {
return exact[1];
}
const combined = candidates.join(" ");
const partial = CHANNEL_LOGOS.find(([key]) => combined.includes(key));
return partial?.[1] || "";
}
function getChannelIconURL(item) {
return String(
item?.channel?.channelIcon || item?.details?.channelIcon || getLocalChannelIconURL(item)
).trim();
}
function getChannelAbbr(item) {
const candidates = [getChannelCode(item), getChannelName(item)];
for (const value of candidates) {
const segments = String(value || "")
.split(/[_\s/-]+/)
.map((segment) => segment.replace(/[^A-Za-z0-9]/g, ""))
.filter(Boolean);
if (segments.length) {
return segments[segments.length - 1].slice(0, 5).toUpperCase();
}
}
return "PAY";
}
function channelIconHTML(item) {
const name = getChannelName(item);
const icon = getChannelIconURL(item);
if (icon) {
return `<span class="channel-icon"><img src="${escapeAttribute(icon)}" alt="${escapeAttribute(
name
)}" /></span>`;
}
return `<span class="channel-icon channel-fallback">${escapeHTML(getChannelAbbr(item))}</span>`;
}
function getAmountText(item) { function getAmountText(item) {
const content = item?.content || item?.obtainGoldsQuantity || "-"; const content = item?.content || item?.obtainGoldsQuantity || "-";
return `${content} ${item?.type || ""}`.trim(); return `${content} ${item?.type || ""}`.trim();
} }
function currentCurrency(item = null) {
return String(
item?.currency || state.commodityCard?.payCountry?.currency || currentCountry()?.currency || "USD"
)
.trim()
.toUpperCase();
}
function formatMoney(value, currency = "") {
if (value === null || value === undefined || value === "") {
return "-";
}
const number = Number(value);
if (!Number.isFinite(number)) {
return String(value);
}
const zeroDecimalCurrencies = new Set(["IDR", "JPY", "KRW", "VND"]);
const maximumFractionDigits = zeroDecimalCurrencies.has(currency) ? 0 : 2;
return number.toLocaleString("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits,
});
}
function getPriceText(item) { function getPriceText(item) {
const currency = item?.currency || state.commodityCard?.payCountry?.currency || "USD"; const currency = currentCurrency(item);
return `${item?.amount || item?.amountUsd || "-"} ${currency}`; return `${currency} ${formatMoney(item?.amount ?? item?.amountUsd, currency)}`;
}
function getUsdPriceText(item) {
return `USD ${formatMoney(item?.amountUsd ?? item?.amount ?? "-", "USD")}`;
}
function getCountryCodes(item) {
const country = item?.country || {};
return [
item?.countryCode,
country?.alphaTwo,
country?.alphaThree,
...(Array.isArray(country?.countryCodeAliases) ? country.countryCodeAliases : []),
]
.filter(Boolean)
.map((value) => String(value).trim().toUpperCase())
.filter(Boolean);
}
function getCountryId(item) {
return String(item?.countryId || item?.country?.id || "").trim();
}
function mergeCompatibleCountries(regionCountries, openCountries) {
const compatibleCountryMap = new Map();
openCountries.forEach((item) => {
const countryId = getCountryId(item);
if (countryId && !compatibleCountryMap.has(countryId)) {
compatibleCountryMap.set(countryId, item);
}
});
return regionCountries.map((item) => {
if (item?.payCountryId) {
return item;
}
const compatibleCountry = compatibleCountryMap.get(getCountryId(item));
if (!compatibleCountry) {
return item;
}
return {
...item,
payCountryId: compatibleCountry.payCountryId || compatibleCountry.id,
currency: item.currency || compatibleCountry.currency,
};
});
} }
function ensureSelectedGoods() { function ensureSelectedGoods() {
@ -312,11 +579,53 @@ function ensureSelectedGoods() {
goods.find((item) => String(item.id) === state.selectedGoodsId) || goods.find((item) => String(item.id) === state.selectedGoodsId) ||
goods.find((item) => item.type === state.productType) || goods.find((item) => item.type === state.productType) ||
goods[0]; goods[0];
state.selectedGoods = matched; const payableGoods = goodsHasChannel(matched)
state.selectedGoodsId = String(matched.id); ? matched
: goods.find((item) => goodsHasChannel(item)) || matched;
state.selectedGoods = payableGoods;
state.selectedGoodsId = String(payableGoods.id);
}
function renderProductLoading() {
els.productList.innerHTML = `
<div class="amount-item skeleton" aria-hidden="true">
<span class="amount-copy">
<span class="skeleton-line strong"></span>
<span class="skeleton-line short"></span>
</span>
<span class="price-copy">
<span class="skeleton-line price"></span>
<span class="skeleton-line short"></span>
</span>
</div>
`;
}
function renderChannelLoading() {
els.goRecharge.hidden = true;
els.channelList.innerHTML = Array.from({ length: 8 })
.map(
() => `
<div class="channel-button skeleton" aria-hidden="true">
<span class="skeleton-icon"></span>
<span class="skeleton-line short"></span>
</div>
`
)
.join("");
} }
function renderProducts() { function renderProducts() {
if (state.loading) {
renderProductLoading();
return;
}
if (state.unsupportedRegion) {
els.productList.innerHTML = "";
return;
}
const goods = getGoodsList(); const goods = getGoodsList();
ensureSelectedGoods(); ensureSelectedGoods();
@ -327,21 +636,28 @@ function renderProducts() {
els.productList.innerHTML = goods els.productList.innerHTML = goods
.map((item) => { .map((item) => {
const selected = String(item.id) === state.selectedGoodsId; const payable = goodsHasChannel(item);
const selected = payable && String(item.id) === state.selectedGoodsId;
const amount = item?.content || item?.obtainGoldsQuantity || "-";
const type = item?.type || "Recharge";
return ` return `
<button <button
type="button" type="button"
class="amount-item${selected ? " selected" : ""}" class="amount-item${selected ? " selected" : ""}"
data-goods-id="${item.id}" data-goods-id="${escapeAttribute(item.id)}"
${payable ? "" : "disabled"}
> >
<span class="check"></span> <span class="check"></span>
<span class="amount-copy"> <span class="amount-copy">
<span class="amount-value">${item.content || item.obtainGoldsQuantity || "-"}</span> <span class="amount-value">
<span class="amount-meta">${item.type || "Recharge"}</span> <span class="coin-icon" aria-hidden="true"></span>
${escapeHTML(amount)}
</span>
<span class="amount-meta">${escapeHTML(type)}</span>
</span> </span>
<span class="price-copy"> <span class="price-copy">
<strong>${item.amount || item.amountUsd || "-"}</strong> <strong>${escapeHTML(getPriceText(item))}</strong>
<span>USD ${item.amountUsd || "-"}</span> <span>${escapeHTML(getUsdPriceText(item))}</span>
</span> </span>
</button> </button>
`; `;
@ -355,12 +671,50 @@ function renderProducts() {
state.selectedGoods = state.selectedGoods =
getGoodsList().find((item) => String(item.id) === goodsId) || null; getGoodsList().find((item) => String(item.id) === goodsId) || null;
renderProducts(); renderProducts();
renderChannels();
}); });
}); });
} }
function ensureSelectedChannel(channels) {
if (!channels.length) {
state.selectedChannelCode = "";
return;
}
const selectedExists = channels.some(
(item) => String(getChannelCode(item)) === String(state.selectedChannelCode)
);
if (!selectedExists) {
state.selectedChannelCode = getChannelCode(channels[0]);
}
}
function renderGoRecharge(channels = getChannelList()) {
const hasSelection = channels.some(
(item) => String(getChannelCode(item)) === String(state.selectedChannelCode)
);
els.goRecharge.hidden = !channels.length;
els.goRecharge.disabled = !hasSelection || Boolean(state.pendingChannelCode);
els.goRecharge.textContent = state.pendingChannelCode ? "Processing..." : "Go Recharge";
}
function renderChannels() { function renderChannels() {
if (state.loading) {
renderChannelLoading();
return;
}
if (state.unsupportedRegion) {
els.channelList.innerHTML = "";
els.goRecharge.hidden = true;
return;
}
const channels = getChannelList(); const channels = getChannelList();
ensureSelectedChannel(channels);
renderGoRecharge(channels);
if (!channels.length) { if (!channels.length) {
els.channelList.innerHTML = '<div class="empty">No payment method available.</div>'; els.channelList.innerHTML = '<div class="empty">No payment method available.</div>';
return; return;
@ -369,46 +723,47 @@ function renderChannels() {
els.channelList.innerHTML = channels els.channelList.innerHTML = channels
.map((item) => { .map((item) => {
const code = getChannelCode(item); const code = getChannelCode(item);
const pending = state.pendingChannelCode === code; const selected = String(code) === String(state.selectedChannelCode);
const name = getChannelName(item);
return ` return `
<button <button
type="button" type="button"
class="channel-button" class="channel-button${selected ? " selected" : ""}"
data-channel-code="${code}" data-channel-code="${escapeAttribute(code)}"
${pending ? "disabled" : ""} aria-pressed="${selected ? "true" : "false"}"
${state.pendingChannelCode ? "disabled" : ""}
> >
<span class="channel-logo"> ${channelIconHTML(item)}
<img src="${LOGO_SRC}" alt="MiFaPay" /> <span class="channel-name">${escapeHTML(name)}</span>
</span>
<span class="channel-name">${pending ? "Processing..." : "Pay with MiFaPay"}</span>
</button> </button>
`; `;
}) })
.join(""); .join("");
els.channelList.querySelectorAll("[data-channel-code]").forEach((button) => { els.channelList.querySelectorAll("[data-channel-code]").forEach((button) => {
button.addEventListener("click", async () => { button.addEventListener("click", () => {
const channelCode = button.getAttribute("data-channel-code"); const channelCode = button.getAttribute("data-channel-code");
try { state.selectedChannelCode = channelCode;
await createOrder(channelCode); setStatus("");
} catch (error) { renderChannels();
state.pendingChannelCode = "";
renderChannels();
setStatus(extractErrorMessage(error));
}
}); });
}); });
} }
function selectCountryByProfile() { function selectCountryByProfile() {
const userCountryCode = state.userProfile?.countryCode; const userCountryCode = String(state.userProfile?.countryCode || "").trim().toUpperCase();
if (!userCountryCode) { const userCountryId = String(state.userProfile?.countryId || "").trim();
if (!userCountryCode && !userCountryId) {
return false; return false;
} }
const matched = state.countries.find((item) => const matched = state.countries.find((item) => {
getCountryLabel(item).toUpperCase().includes(userCountryCode.toUpperCase()) const itemCountryId = getCountryId(item);
); if (userCountryId && itemCountryId === userCountryId) {
return true;
}
return userCountryCode && getCountryCodes(item).includes(userCountryCode);
});
if (!matched) { if (!matched) {
return false; return false;
@ -469,9 +824,18 @@ async function refreshMeta() {
if (!state.countries.length) { if (!state.countries.length) {
state.selectedCountryId = ""; state.selectedCountryId = "";
state.commodityCard = null; state.commodityCard = null;
state.loading = false;
setUnsupportedRegion(true);
renderProducts(); renderProducts();
renderChannels(); renderChannels();
throw new Error("No supported country"); setStatus("");
return;
}
if (state.countries.some((item) => !item?.payCountryId)) {
const countryResp = await apiGet("/order/web/pay/country");
const openCountries = Array.isArray(countryResp?.body) ? countryResp.body : [];
state.countries = mergeCompatibleCountries(state.countries, openCountries);
} }
if ( if (
@ -486,12 +850,14 @@ async function refreshMeta() {
const commodityResp = await apiGet( const commodityResp = await apiGet(
`/order/web/pay/commodity?applicationId=${encodeURIComponent( `/order/web/pay/commodity?applicationId=${encodeURIComponent(
state.applicationId state.applicationId
)}&payCountryId=${encodeURIComponent(currentCountryId())}&type=${encodeURIComponent( )}&payCountryId=${encodeURIComponent(currentPayCountryId())}&type=${encodeURIComponent(
state.productType state.productType
)}&regionId=${encodeURIComponent(currentRegionId())}` )}&regionId=${encodeURIComponent(currentRegionId())}`
); );
state.commodityCard = commodityResp?.body || {}; state.commodityCard = commodityResp?.body || {};
state.loading = false;
setUnsupportedRegion(!hasAvailablePayment());
renderProducts(); renderProducts();
renderChannels(); renderChannels();
setStatus(""); setStatus("");
@ -514,10 +880,15 @@ async function createOrder(channelCode) {
throw new Error("Select an amount first"); throw new Error("Select an amount first");
} }
if (!channelCode) {
throw new Error("Select a payment method first");
}
if (!currentUserId()) { if (!currentUserId()) {
throw new Error("Account not available"); throw new Error("Account not available");
} }
state.selectedChannelCode = channelCode;
state.pendingChannelCode = channelCode; state.pendingChannelCode = channelCode;
setStatus(""); setStatus("");
renderChannels(); renderChannels();
@ -525,7 +896,7 @@ async function createOrder(channelCode) {
const payload = { const payload = {
applicationId: state.applicationId, applicationId: state.applicationId,
goodsId: state.selectedGoodsId, goodsId: state.selectedGoodsId,
payCountryId: currentCountryId(), payCountryId: currentPayCountryId(),
userId: currentUserId(), userId: currentUserId(),
channelCode, channelCode,
newVersion: true, newVersion: true,
@ -546,12 +917,27 @@ async function reloadPageData() {
} }
async function init() { async function init() {
state.loading = true;
setUnsupportedRegion(false);
renderProducts(); renderProducts();
renderChannels(); renderChannels();
els.goRecharge.addEventListener("click", async () => {
try {
await createOrder(state.selectedChannelCode);
} catch (error) {
state.pendingChannelCode = "";
renderChannels();
setStatus(extractErrorMessage(error));
}
});
try { try {
await reloadPageData(); await reloadPageData();
} catch (error) { } catch (error) {
state.loading = false;
setUnsupportedRegion(false);
renderProducts();
renderChannels();
setStatus(extractErrorMessage(error)); setStatus(extractErrorMessage(error));
} }
} }

BIN
h5/mifa-pay/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

View File

@ -12,10 +12,13 @@
--bg: #08131f; --bg: #08131f;
--surface: rgba(15, 28, 42, 0.92); --surface: rgba(15, 28, 42, 0.92);
--surface-strong: #102030; --surface-strong: #102030;
--line: rgba(214, 170, 82, 0.18); --line: rgba(72, 224, 208, 0.2);
--line-strong: rgba(214, 170, 82, 0.42); --line-strong: rgba(72, 224, 208, 0.78);
--text: #f6f1e6; --text: #f6f1e6;
--muted: #bcae8d; --muted: #bed4d1;
--accent: #48e0d0;
--accent-strong: #1baaa3;
--accent-soft: rgba(72, 224, 208, 0.16);
--gold: #d6aa52; --gold: #d6aa52;
--gold-strong: #c49335; --gold-strong: #c49335;
--danger: #ff9f9f; --danger: #ff9f9f;
@ -38,8 +41,9 @@
sans-serif; sans-serif;
color: var(--text); color: var(--text);
background: background:
radial-gradient(circle at top, rgba(214, 170, 82, 0.1), transparent 24%), linear-gradient(180deg, rgba(8, 19, 31, 0.44), rgba(8, 19, 31, 0.72)),
linear-gradient(180deg, #091321 0%, var(--bg) 100%); url("./bg.png") center top / cover no-repeat,
var(--bg);
} }
button { button {
@ -59,9 +63,12 @@
font-size: 30px; font-size: 30px;
line-height: 1; line-height: 1;
font-weight: 800; font-weight: 800;
color: var(--gold); color: #f8fbff;
text-align: center; text-align: center;
letter-spacing: 0.03em; letter-spacing: 0.03em;
text-shadow:
0 2px 10px rgba(8, 19, 31, 0.88),
0 0 18px rgba(72, 224, 208, 0.3);
} }
.list { .list {
@ -69,39 +76,48 @@
gap: 12px; gap: 12px;
} }
.amount-item, .amount-item {
.channel-button {
position: relative; position: relative;
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 16px 18px; padding: 16px 54px 16px 18px;
border-radius: 22px; border-radius: 22px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: background:
linear-gradient(180deg, rgba(214, 170, 82, 0.04), transparent 60%), linear-gradient(180deg, rgba(72, 224, 208, 0.07), transparent 62%),
var(--surface); var(--surface);
box-shadow: var(--shadow); box-shadow: var(--shadow);
color: var(--text); color: var(--text);
text-align: left; text-align: left;
transition: transition:
transform 0.18s ease, transform 0.18s ease,
background 0.18s ease,
border-color 0.18s ease, border-color 0.18s ease,
box-shadow 0.18s ease; box-shadow 0.18s ease;
} }
.amount-item:active, .amount-item:active {
.channel-button:active {
transform: scale(0.988); transform: scale(0.988);
} }
.amount-item.selected { .amount-item.selected {
border-color: var(--line-strong); border-color: var(--line-strong);
background:
linear-gradient(135deg, rgba(72, 224, 208, 0.22), rgba(13, 30, 44, 0.96)),
var(--surface-strong);
transform: scale(1.012);
box-shadow: box-shadow:
0 0 0 1px rgba(214, 170, 82, 0.12), 0 0 0 1px rgba(72, 224, 208, 0.22),
0 16px 36px rgba(0, 0, 0, 0.24); 0 0 22px rgba(72, 224, 208, 0.28),
0 16px 36px rgba(0, 0, 0, 0.28);
}
.amount-item:disabled {
cursor: not-allowed;
opacity: 0.55;
} }
.amount-copy { .amount-copy {
@ -111,12 +127,37 @@
} }
.amount-value { .amount-value {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 24px; font-size: 24px;
line-height: 1; line-height: 1;
font-weight: 800; font-weight: 800;
color: var(--text); color: var(--text);
} }
.coin-icon {
position: relative;
width: 24px;
height: 24px;
flex: 0 0 auto;
border-radius: 50%;
background:
radial-gradient(circle at 35% 28%, #fff5b8 0 18%, transparent 19%),
linear-gradient(135deg, #ffd96a, #d79a26);
box-shadow:
inset 0 0 0 2px rgba(142, 91, 13, 0.46),
0 0 12px rgba(255, 217, 106, 0.36);
}
.coin-icon::after {
content: "";
position: absolute;
inset: 6px;
border-radius: 50%;
border: 2px solid rgba(122, 78, 10, 0.48);
}
.amount-meta { .amount-meta {
font-size: 11px; font-size: 11px;
line-height: 1; line-height: 1;
@ -130,12 +171,14 @@
display: grid; display: grid;
gap: 6px; gap: 6px;
flex-shrink: 0; flex-shrink: 0;
max-width: 46%;
} }
.price-copy strong { .price-copy strong {
font-size: 22px; font-size: 20px;
line-height: 1; line-height: 1;
color: var(--gold); color: var(--accent);
white-space: nowrap;
} }
.price-copy span { .price-copy span {
@ -145,17 +188,20 @@
.check { .check {
position: absolute; position: absolute;
top: 14px; top: 50%;
right: 14px; right: 18px;
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 50%; border-radius: 50%;
border: 1px solid rgba(214, 170, 82, 0.3); border: 1px solid rgba(72, 224, 208, 0.36);
transform: translateY(-50%);
pointer-events: none;
} }
.amount-item.selected .check { .amount-item.selected .check {
border-color: transparent; border-color: transparent;
background: linear-gradient(135deg, #efcf82, var(--gold-strong)); background: linear-gradient(135deg, #8cf5ec, var(--accent-strong));
box-shadow: 0 0 14px rgba(72, 224, 208, 0.45);
} }
.amount-item.selected .check::after { .amount-item.selected .check::after {
@ -174,32 +220,107 @@
margin-top: 18px; margin-top: 18px;
} }
.channel-button { .channel-grid {
justify-content: flex-start; display: grid;
padding-right: 18px; grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
} }
.channel-logo { .channel-button {
width: 78px; position: relative;
height: 44px; width: 100%;
min-height: 84px;
display: grid;
align-content: center;
justify-items: center;
gap: 8px;
padding: 10px 6px;
border-radius: 16px;
border: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(72, 224, 208, 0.06), transparent 62%),
var(--surface);
box-shadow: var(--shadow);
color: var(--text);
text-align: center;
transition:
transform 0.18s ease,
background 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.channel-button:active {
transform: scale(0.98);
}
.channel-button.selected {
border-color: var(--line-strong);
background:
linear-gradient(135deg, rgba(72, 224, 208, 0.28), rgba(13, 30, 44, 0.97)),
var(--surface-strong);
transform: translateY(-2px) scale(1.045);
box-shadow:
0 0 0 1px rgba(72, 224, 208, 0.26),
0 0 22px rgba(72, 224, 208, 0.34),
0 18px 40px rgba(0, 0, 0, 0.32);
}
.channel-button.selected::after {
content: "";
position: absolute;
top: 8px;
right: 8px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px rgba(72, 224, 208, 0.68);
}
.channel-icon {
width: 54px;
height: 38px;
display: grid; display: grid;
place-items: center; place-items: center;
flex-shrink: 0; flex-shrink: 0;
border-radius: 14px; padding: 4px;
border-radius: 10px;
background: #ffffff; background: #ffffff;
overflow: hidden; overflow: hidden;
} }
.channel-logo img { .channel-icon img {
display: block; display: block;
width: 68px; max-width: 48px;
height: auto; max-height: 30px;
object-fit: contain;
}
.channel-fallback {
width: 42px;
height: 42px;
padding: 0;
border-radius: 12px;
background: linear-gradient(135deg, #8cf5ec, var(--accent-strong));
color: #0c1520;
font-size: 12px;
line-height: 1;
font-weight: 800;
} }
.channel-name { .channel-name {
font-size: 16px; width: 100%;
min-height: 24px;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 10px;
line-height: 1.2;
font-weight: 700; font-weight: 700;
color: var(--text); color: var(--text);
overflow-wrap: anywhere;
} }
.channel-button:disabled { .channel-button:disabled {
@ -207,6 +328,32 @@
opacity: 0.55; opacity: 0.55;
} }
.recharge-button {
width: 100%;
margin-top: 14px;
padding: 14px 18px;
border-radius: 18px;
background: linear-gradient(135deg, #8cf5ec, var(--accent-strong));
color: #0c1520;
font-size: 16px;
font-weight: 800;
box-shadow:
0 0 18px rgba(72, 224, 208, 0.28),
0 16px 32px rgba(0, 0, 0, 0.28);
transition:
transform 0.18s ease,
box-shadow 0.18s ease;
}
.recharge-button:active {
transform: scale(0.985);
}
.recharge-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.status { .status {
display: none; display: none;
margin-top: 14px; margin-top: 14px;
@ -225,6 +372,7 @@
} }
.empty { .empty {
grid-column: 1 / -1;
padding: 16px; padding: 16px;
border-radius: 20px; border-radius: 20px;
border: 1px dashed var(--line); border: 1px dashed var(--line);
@ -232,18 +380,136 @@
text-align: center; text-align: center;
font-size: 13px; font-size: 13px;
} }
.unsupported-state {
display: none;
min-height: calc(100vh - 130px);
place-items: center;
padding: 36px 8px;
}
.unsupported-state.show {
display: grid;
}
.unsupported-panel {
width: 100%;
max-width: 360px;
display: grid;
justify-items: center;
gap: 14px;
padding: 28px 22px;
border-radius: 24px;
border: 1px solid rgba(72, 224, 208, 0.46);
background:
radial-gradient(circle at top, rgba(72, 224, 208, 0.18), transparent 58%),
rgba(8, 19, 31, 0.82);
box-shadow:
0 0 0 1px rgba(248, 251, 255, 0.06),
0 0 28px rgba(72, 224, 208, 0.24),
0 18px 44px rgba(0, 0, 0, 0.34);
text-align: center;
}
.unsupported-icon {
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: 50%;
background: linear-gradient(135deg, #8cf5ec, var(--accent-strong));
color: #0c1520;
font-size: 24px;
font-weight: 900;
box-shadow: 0 0 18px rgba(72, 224, 208, 0.42);
}
.unsupported-title {
margin: 0;
max-width: 290px;
color: #f8fbff;
font-size: 20px;
line-height: 1.35;
font-weight: 800;
text-shadow: 0 2px 10px rgba(8, 19, 31, 0.72);
}
.skeleton {
position: relative;
overflow: hidden;
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
rgba(248, 251, 255, 0.12),
transparent
);
animation: shimmer 1.25s infinite;
}
.skeleton-line,
.skeleton-icon {
display: block;
border-radius: 999px;
background: rgba(248, 251, 255, 0.12);
}
.skeleton-line.strong {
width: 92px;
height: 22px;
}
.skeleton-line.short {
width: 64px;
height: 10px;
}
.skeleton-line.price {
width: 104px;
height: 18px;
margin-left: auto;
}
.skeleton-icon {
width: 54px;
height: 38px;
border-radius: 10px;
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
</style> </style>
</head> </head>
<body> <body>
<div class="page"> <div class="page">
<h1>Recharge</h1> <h1>Recharge</h1>
<div id="productList" class="list"></div> <div id="productList" class="list"></div>
<div id="channelList" class="list channel-block"></div> <div id="channelList" class="channel-grid channel-block"></div>
<div id="unsupportedMessage" class="unsupported-state" hidden>
<div class="unsupported-panel">
<div class="unsupported-icon" aria-hidden="true">!</div>
<p class="unsupported-title">
Sorry, this region is currently not supported.
</p>
</div>
</div>
<button id="goRecharge" class="recharge-button" type="button" disabled hidden>
Go Recharge
</button>
<div id="pageStatus" class="status" aria-live="polite"></div> <div id="pageStatus" class="status" aria-live="polite"></div>
</div> </div>
<input id="applicationId" type="hidden" /> <input id="applicationId" type="hidden" />
<script type="module" src="./app.js"></script> <script type="module" src="./app.js?v=20260501-6"></script>
</body> </body>
</html> </html>

BIN
h5/mifa-pay/logos/bca.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
h5/mifa-pay/logos/bhim.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
h5/mifa-pay/logos/bni.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
h5/mifa-pay/logos/bri.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
h5/mifa-pay/logos/dana.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
h5/mifa-pay/logos/ovo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
h5/mifa-pay/logos/paytm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
h5/mifa-pay/logos/qris.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
h5/mifa-pay/logos/upi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB