1010 lines
37 KiB
JavaScript
1010 lines
37 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
var DESIGN_WIDTH = 375;
|
||
var MAX_LAYOUT_WIDTH = 480;
|
||
var OUTFIT_INITIAL_SCROLL = 53;
|
||
var VERSION_RETRY_LIMIT = 1;
|
||
var FALLBACK_OUTFIT = './assets/fami/layers/outfit-fallback.png';
|
||
var UNKNOWN_ICON = './assets/fami/icons/unknown-report.svg';
|
||
var ICON_FILES = {
|
||
vip_badge_identity: './assets/fami/icons/award-1.svg',
|
||
vip_medal: './assets/fami/icons/award-2.svg',
|
||
avatar_frame: './assets/fami/icons/avatar-frame.svg',
|
||
vip_title: './assets/fami/icons/username.svg',
|
||
chat_bubble: './assets/fami/icons/chat.svg',
|
||
vip_gift: './assets/fami/icons/gift.svg',
|
||
entry_effect: './assets/fami/icons/entry.svg',
|
||
visitor_history: './assets/fami/icons/profile-circle-1.svg',
|
||
voice_wave: './assets/fami/icons/radio.svg',
|
||
profile_card: './assets/fami/icons/profile-card.svg',
|
||
custom_room_background: './assets/fami/icons/profile-circle-2.svg',
|
||
room_image_message: './assets/fami/icons/file-gif.svg',
|
||
animated_avatar: './assets/fami/icons/file-gif.svg',
|
||
animated_room_cover: './assets/fami/icons/file-gif.svg',
|
||
mic_skin: './assets/fami/icons/microphone.svg',
|
||
room_border: './assets/fami/icons/shield-1.svg',
|
||
vehicle: './assets/fami/icons/vehicle.svg',
|
||
colored_room_name: './assets/fami/icons/courier-1.svg',
|
||
colored_nickname: './assets/fami/icons/courier-2.svg',
|
||
colored_id: './assets/fami/icons/id-currency.svg',
|
||
gift_tray_skin: './assets/fami/icons/gift.svg',
|
||
hide_profile_data: './assets/fami/icons/hidden.svg',
|
||
custom_avatar_frame: './assets/fami/icons/avatar-frame.svg',
|
||
leaderboard_invisible: './assets/fami/icons/hidden.svg',
|
||
daily_coin_rebate: './assets/fami/icons/currency.svg',
|
||
custom_profile_card: './assets/fami/icons/profile-card.svg',
|
||
anonymous_profile_visit: './assets/fami/icons/anonymous.svg',
|
||
custom_gift: './assets/fami/icons/gift.svg',
|
||
room_entry_notice: './assets/fami/icons/entry.svg',
|
||
custom_pretty_id: './assets/fami/icons/pretty-id.svg',
|
||
custom_vehicle: './assets/fami/icons/vehicle.svg',
|
||
anti_kick: './assets/fami/icons/anti-kick.svg',
|
||
anti_mute: './assets/fami/icons/shield-3.svg',
|
||
online_global_notice: './assets/fami/icons/notice.svg',
|
||
unknown: UNKNOWN_ICON,
|
||
};
|
||
|
||
var state = {
|
||
packages: [],
|
||
selectedLevel: 0,
|
||
vipState: emptyVipState(),
|
||
programConfig: {},
|
||
pageState: 'loading',
|
||
purchasing: false,
|
||
settingsSaving: false,
|
||
stickyNavigationActive: false,
|
||
loadError: null,
|
||
lockedScrollTop: 0,
|
||
lastFocused: null,
|
||
mediaPlayer: null,
|
||
};
|
||
|
||
function byId(id) {
|
||
return document.getElementById(id);
|
||
}
|
||
|
||
function text(value) {
|
||
return String(value === undefined || value === null ? '' : value);
|
||
}
|
||
|
||
function number(value) {
|
||
var result = Number(value || 0);
|
||
return Number.isFinite(result) ? result : 0;
|
||
}
|
||
|
||
function t(key, fallback) {
|
||
if (window.HyAppI18n && window.HyAppI18n.t) {
|
||
return window.HyAppI18n.t(key, fallback || key);
|
||
}
|
||
return fallback || key;
|
||
}
|
||
|
||
function interpolate(template, values) {
|
||
return text(template).replace(/\{([a-z_]+)\}/gi, function (_, key) {
|
||
return Object.prototype.hasOwnProperty.call(values, key)
|
||
? text(values[key])
|
||
: '';
|
||
});
|
||
}
|
||
|
||
function locale() {
|
||
return window.HyAppI18n && window.HyAppI18n.lang
|
||
? window.HyAppI18n.lang()
|
||
: 'en';
|
||
}
|
||
|
||
function emptyVipState() {
|
||
return {
|
||
paid_vip: {},
|
||
effective_vip: {},
|
||
effective_source: 'none',
|
||
effective_benefits: [],
|
||
user_settings: {
|
||
room_entry_notice_enabled: true,
|
||
online_global_notice_enabled: true,
|
||
},
|
||
program_config: {},
|
||
};
|
||
}
|
||
|
||
function ensureFamiTenant() {
|
||
var url = new URL(window.location.href);
|
||
if (url.searchParams.get('app_code') !== 'fami') {
|
||
// common/api.js 只信任 URL 中显式 app_code 作为 X-App-Code,避免跨页面缓存串租户。
|
||
// Fami 专属入口因此在任何请求发出前把租户写入当前 URL,而不是仅修改 localStorage。
|
||
url.searchParams.set('app_code', 'fami');
|
||
window.history.replaceState(null, '', url.toString());
|
||
}
|
||
if (window.HyAppAPI && window.HyAppAPI.setAppCode) {
|
||
window.HyAppAPI.setAppCode('fami');
|
||
}
|
||
}
|
||
|
||
function syncViewport() {
|
||
var mount = byId('famiVipMount');
|
||
var page = byId('famiVipPage');
|
||
if (!mount || !page) return;
|
||
var viewportWidth = Math.max(1, window.innerWidth || DESIGN_WIDTH);
|
||
var viewportHeight = Math.max(
|
||
560,
|
||
window.innerHeight || document.documentElement.clientHeight || 812
|
||
);
|
||
// 页面仍以 375px 设计坐标绘制,手机宽度整体等比适配,确保所有独立图层之间的
|
||
// 几何关系不会因零散百分比换算而漂移;只在桌面预览时限制到 480px,避免移动端
|
||
// 页面被无限放大,同时 320~480px 的常见设备宽度都会真正铺满视口。
|
||
var layoutWidth = Math.min(MAX_LAYOUT_WIDTH, viewportWidth);
|
||
var scale = layoutWidth / DESIGN_WIDTH;
|
||
mount.style.width = layoutWidth + 'px';
|
||
mount.style.height = viewportHeight + 'px';
|
||
page.style.height = viewportHeight / scale + 'px';
|
||
page.style.transform = 'scale(' + scale + ')';
|
||
syncStickyNavigation();
|
||
}
|
||
|
||
function syncStickyNavigation() {
|
||
var scroll = byId('pageScroll');
|
||
var navigation = byId('stickyNavigation');
|
||
if (!scroll || !navigation) return;
|
||
// 毛玻璃只在导航真正抵达滚动容器顶部后启用;初始 48px 状态栏区域仍展示
|
||
// 原设计背景,避免页面刚开始轻微滚动时就出现一块悬浮的深色玻璃层。
|
||
var threshold = Math.max(0, navigation.offsetTop - 0.5);
|
||
var active = scroll.scrollTop >= threshold;
|
||
if (active === state.stickyNavigationActive) return;
|
||
state.stickyNavigationActive = active;
|
||
navigation.classList.toggle('is-stuck', active);
|
||
}
|
||
|
||
function packageList(payload) {
|
||
return Array.isArray(payload && payload.packages)
|
||
? payload.packages.slice().sort(function (a, b) {
|
||
return (
|
||
number(a.sort_order) - number(b.sort_order) ||
|
||
number(a.level) - number(b.level)
|
||
);
|
||
})
|
||
: [];
|
||
}
|
||
|
||
function normalizeVipState(payload) {
|
||
var source = payload && typeof payload === 'object' ? payload : {};
|
||
var normalized = emptyVipState();
|
||
normalized.paid_vip = source.paid_vip || {};
|
||
normalized.equipped_trial_card = source.equipped_trial_card || null;
|
||
normalized.effective_vip = source.effective_vip || {};
|
||
normalized.effective_source = text(source.effective_source || 'none');
|
||
normalized.effective_benefits = Array.isArray(source.effective_benefits)
|
||
? source.effective_benefits.slice()
|
||
: [];
|
||
normalized.evaluated_at_ms = number(source.evaluated_at_ms);
|
||
normalized.program_config = source.program_config || {};
|
||
normalized.user_settings = Object.assign(
|
||
{},
|
||
normalized.user_settings,
|
||
source.user_settings || {}
|
||
);
|
||
return normalized;
|
||
}
|
||
|
||
function configVersion(payload) {
|
||
var config =
|
||
(payload && payload.program_config) ||
|
||
(payload && payload.state && payload.state.program_config) ||
|
||
{};
|
||
return number(config.config_version);
|
||
}
|
||
|
||
function versionsMatch(packagesPayload, mePayload) {
|
||
var packagesVersion = configVersion(packagesPayload);
|
||
var meVersion = configVersion(mePayload);
|
||
// 旧服务滚动升级时可能仍返回 0;只有双方都声明了版本才能判定冲突。
|
||
// 一旦双方都有版本,绝不提交跨版本 state/package 组合,避免按钮资格与权益列表互相矛盾。
|
||
return !packagesVersion || !meVersion || packagesVersion === meVersion;
|
||
}
|
||
|
||
function selectedPackage() {
|
||
return (
|
||
state.packages.find(function (item) {
|
||
return number(item.level) === number(state.selectedLevel);
|
||
}) || null
|
||
);
|
||
}
|
||
|
||
function chooseInitialLevel(previousLevel) {
|
||
if (!state.packages.length) return 0;
|
||
var requested = number(
|
||
new URL(window.location.href).searchParams.get('level')
|
||
);
|
||
var effective = state.vipState.effective_vip || {};
|
||
var candidates = [previousLevel, requested];
|
||
if (effective.active) candidates.push(number(effective.level));
|
||
for (var index = 0; index < candidates.length; index += 1) {
|
||
var candidate = number(candidates[index]);
|
||
if (
|
||
candidate &&
|
||
state.packages.some(function (item) {
|
||
return number(item.level) === candidate;
|
||
})
|
||
) {
|
||
return candidate;
|
||
}
|
||
}
|
||
var purchasable = state.packages.find(function (item) {
|
||
return item.can_purchase === true;
|
||
});
|
||
return number((purchasable || state.packages[0]).level);
|
||
}
|
||
|
||
function commitResponses(packagesPayload, mePayload) {
|
||
var previousLevel = state.selectedLevel;
|
||
state.packages = packageList(packagesPayload);
|
||
// /vip/me 是当前身份的专用读模型;packages 中的 state 仅作为同版本购买列表快照。
|
||
// 资格集合统一采用 me.state,客户端不按等级或 unlock_level 自行补齐。
|
||
state.vipState = normalizeVipState(
|
||
(mePayload && mePayload.state) ||
|
||
(packagesPayload && packagesPayload.state)
|
||
);
|
||
state.programConfig =
|
||
(packagesPayload && packagesPayload.program_config) ||
|
||
(mePayload && mePayload.program_config) ||
|
||
state.vipState.program_config ||
|
||
{};
|
||
state.selectedLevel = chooseInitialLevel(previousLevel);
|
||
state.loadError = null;
|
||
state.pageState = 'ready';
|
||
renderAll();
|
||
if (window.HyAppBridge && window.HyAppBridge.ready) {
|
||
window.HyAppBridge.ready({
|
||
page: 'fami_vip',
|
||
config_version: number(state.programConfig.config_version),
|
||
});
|
||
}
|
||
}
|
||
|
||
function loadData(attempt) {
|
||
attempt = number(attempt);
|
||
if (!window.HyAppAPI || !window.HyAppAPI.vip) {
|
||
return Promise.reject(new Error('vip_api_unavailable'));
|
||
}
|
||
// 两个请求并发缩短首屏时间;结果在版本校验通过前不会进入可见 state,
|
||
// 任意一支失败都保留上次完整画面,避免出现“新套餐 + 旧会员”的半更新状态。
|
||
return Promise.all([
|
||
window.HyAppAPI.vip.packages(),
|
||
window.HyAppAPI.vip.me(),
|
||
]).then(function (responses) {
|
||
if (!versionsMatch(responses[0], responses[1])) {
|
||
if (attempt < VERSION_RETRY_LIMIT) {
|
||
return loadData(attempt + 1);
|
||
}
|
||
var mismatch = new Error('vip_config_version_mismatch');
|
||
mismatch.code = 'vip_config_version_mismatch';
|
||
throw mismatch;
|
||
}
|
||
commitResponses(responses[0] || {}, responses[1] || {});
|
||
});
|
||
}
|
||
|
||
function setPageState(nextState, message) {
|
||
state.pageState = nextState;
|
||
var page = byId('famiVipPage');
|
||
var loadMessage = byId('loadMessage');
|
||
var retry = byId('retryButton');
|
||
if (page) {
|
||
page.setAttribute('data-page-state', nextState);
|
||
page.setAttribute(
|
||
'aria-busy',
|
||
nextState === 'loading' ? 'true' : 'false'
|
||
);
|
||
}
|
||
if (loadMessage && message) loadMessage.textContent = message;
|
||
if (retry) retry.hidden = nextState !== 'error';
|
||
}
|
||
|
||
function hydrate() {
|
||
setPageState('loading', t('fami_vip.loading', 'Loading...'));
|
||
return loadData(0).catch(function (error) {
|
||
state.loadError = error;
|
||
setPageState(
|
||
'error',
|
||
error && error.code === 'vip_config_version_mismatch'
|
||
? t(
|
||
'fami_vip.version_changed',
|
||
'VIP configuration changed. Please retry.'
|
||
)
|
||
: t('fami_vip.load_failed', 'Unable to load VIP data.')
|
||
);
|
||
});
|
||
}
|
||
|
||
function renderAll() {
|
||
renderLevels();
|
||
renderOutfits();
|
||
renderPrivileges();
|
||
renderBottomBar();
|
||
if (state.pageState === 'ready') setPageState('ready');
|
||
}
|
||
|
||
function renderLevels() {
|
||
var root = byId('levelTabs');
|
||
if (!root) return;
|
||
root.replaceChildren();
|
||
var track = document.createElement('div');
|
||
track.className = 'fami-vip__level-track';
|
||
track.setAttribute('role', 'tablist');
|
||
state.packages.forEach(function (item) {
|
||
var level = number(item.level);
|
||
var selected = level === number(state.selectedLevel);
|
||
var button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'fami-vip__level-button';
|
||
button.dataset.level = text(level);
|
||
button.dataset.testid = 'vip-level-' + level;
|
||
button.setAttribute('role', 'tab');
|
||
button.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||
button.setAttribute('tabindex', selected ? '0' : '-1');
|
||
var label = document.createElement('span');
|
||
label.textContent = text(item.name || 'VIP' + level);
|
||
button.appendChild(label);
|
||
track.appendChild(button);
|
||
});
|
||
root.appendChild(track);
|
||
window.requestAnimationFrame(scrollSelectedLevelIntoView);
|
||
}
|
||
|
||
function scrollSelectedLevelIntoView() {
|
||
var root = byId('levelTabs');
|
||
if (!root) return;
|
||
var selected = root.querySelector('[aria-selected="true"]');
|
||
if (!selected) return;
|
||
var target = Math.max(
|
||
0,
|
||
selected.offsetLeft - (root.clientWidth - selected.clientWidth) / 2
|
||
);
|
||
root.scrollLeft = target;
|
||
}
|
||
|
||
function rewardItems(item) {
|
||
return Array.isArray(item && item.reward_items)
|
||
? item.reward_items.slice()
|
||
: [];
|
||
}
|
||
|
||
function rewardPreview(item) {
|
||
return text(
|
||
item &&
|
||
(item.preview_url ||
|
||
item.cover_url ||
|
||
item.asset_url ||
|
||
item.animation_url)
|
||
);
|
||
}
|
||
|
||
function renderOutfits() {
|
||
var list = byId('outfitList');
|
||
var titleNode = byId('outfitTitle');
|
||
if (!list || !titleNode) return;
|
||
var items = rewardItems(selectedPackage());
|
||
titleNode.textContent =
|
||
t('fami_vip.exclusive_outfit', 'Exclusive Outfit') +
|
||
'(' +
|
||
items.length +
|
||
'/' +
|
||
items.length +
|
||
')';
|
||
list.replaceChildren();
|
||
if (!items.length) {
|
||
var empty = document.createElement('p');
|
||
empty.className = 'fami-vip__outfit-empty';
|
||
empty.textContent = t(
|
||
'fami_vip.no_outfits',
|
||
'No exclusive outfits for this level.'
|
||
);
|
||
list.appendChild(empty);
|
||
return;
|
||
}
|
||
items.forEach(function (item, index) {
|
||
var card = document.createElement('button');
|
||
card.type = 'button';
|
||
card.className = 'fami-vip__outfit-card';
|
||
card.dataset.outfitIndex = text(index);
|
||
card.dataset.testid = 'vip-outfit-card';
|
||
card.setAttribute(
|
||
'aria-label',
|
||
text(item.name || t('fami_vip.outfit', 'VIP outfit'))
|
||
);
|
||
|
||
var skin = document.createElement('img');
|
||
skin.className = 'fami-vip__outfit-skin';
|
||
skin.src = './assets/fami/layers/outfit-card-glow.png';
|
||
skin.alt = '';
|
||
skin.setAttribute('aria-hidden', 'true');
|
||
|
||
var media = document.createElement('img');
|
||
media.className = 'fami-vip__outfit-media';
|
||
media.alt = '';
|
||
media.loading = index > 2 ? 'lazy' : 'eager';
|
||
media.src = rewardPreview(item) || FALLBACK_OUTFIT;
|
||
media.addEventListener('error', function () {
|
||
if (!media.src.endsWith(FALLBACK_OUTFIT.replace('./', ''))) {
|
||
media.src = FALLBACK_OUTFIT;
|
||
}
|
||
});
|
||
|
||
var name = document.createElement('span');
|
||
name.className = 'fami-vip__outfit-name';
|
||
name.textContent = text(
|
||
item.name || t('fami_vip.outfit', 'VIP outfit')
|
||
);
|
||
|
||
card.appendChild(skin);
|
||
card.appendChild(media);
|
||
card.appendChild(name);
|
||
list.appendChild(card);
|
||
});
|
||
window.requestAnimationFrame(function () {
|
||
var viewport = byId('outfitViewport');
|
||
if (viewport) {
|
||
viewport.scrollLeft =
|
||
list.scrollWidth > viewport.clientWidth
|
||
? OUTFIT_INITIAL_SCROLL
|
||
: 0;
|
||
}
|
||
});
|
||
}
|
||
|
||
function benefitItems(item) {
|
||
return Array.isArray(item && item.benefits)
|
||
? item.benefits.slice().sort(function (a, b) {
|
||
return (
|
||
number(a.sort_order) - number(b.sort_order) ||
|
||
text(a.benefit_code).localeCompare(text(b.benefit_code))
|
||
);
|
||
})
|
||
: [];
|
||
}
|
||
|
||
function effectiveBenefitCodes() {
|
||
var codes = Object.create(null);
|
||
(state.vipState.effective_benefits || []).forEach(function (item) {
|
||
var code = text(item && item.benefit_code);
|
||
if (code) codes[code] = true;
|
||
});
|
||
return codes;
|
||
}
|
||
|
||
function iconFile(benefit) {
|
||
var iconKey = text(
|
||
benefit && (benefit.icon_key || benefit.benefit_code)
|
||
).toLowerCase();
|
||
return ICON_FILES[iconKey] || UNKNOWN_ICON;
|
||
}
|
||
|
||
function renderPrivileges() {
|
||
var grid = byId('privilegeGrid');
|
||
var titleNode = byId('privilegeTitle');
|
||
if (!grid || !titleNode) return;
|
||
var items = benefitItems(selectedPackage());
|
||
var unlockedCodes = effectiveBenefitCodes();
|
||
var unlockedCount = items.filter(function (item) {
|
||
return !!unlockedCodes[text(item.benefit_code)];
|
||
}).length;
|
||
titleNode.textContent =
|
||
t('fami_vip.vip_privileges', 'VIP Privileges') +
|
||
'(' +
|
||
unlockedCount +
|
||
'/' +
|
||
items.length +
|
||
')';
|
||
grid.replaceChildren();
|
||
if (!items.length) {
|
||
var empty = document.createElement('p');
|
||
empty.className = 'fami-vip__privilege-empty';
|
||
empty.textContent = t(
|
||
'fami_vip.no_privileges',
|
||
'No privileges for this level.'
|
||
);
|
||
grid.appendChild(empty);
|
||
return;
|
||
}
|
||
items.forEach(function (item) {
|
||
var code = text(item.benefit_code);
|
||
var unlocked = !!unlockedCodes[code];
|
||
var button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className =
|
||
'fami-vip__privilege-item' +
|
||
(unlocked ? ' is-unlocked' : ' is-locked');
|
||
button.dataset.benefitCode = code;
|
||
button.dataset.testid = 'vip-privilege-item';
|
||
button.setAttribute(
|
||
'aria-label',
|
||
text(item.name || code) +
|
||
', ' +
|
||
(unlocked
|
||
? t('fami_vip.unlocked', 'Unlocked')
|
||
: t('fami_vip.locked', 'Locked'))
|
||
);
|
||
|
||
var icon = document.createElement('span');
|
||
icon.className = 'fami-vip__privilege-icon';
|
||
var image = document.createElement('img');
|
||
image.src = iconFile(item);
|
||
image.alt = '';
|
||
image.setAttribute('aria-hidden', 'true');
|
||
image.addEventListener(
|
||
'error',
|
||
function () {
|
||
image.src = UNKNOWN_ICON;
|
||
},
|
||
{ once: true }
|
||
);
|
||
icon.appendChild(image);
|
||
|
||
var name = document.createElement('span');
|
||
name.className = 'fami-vip__privilege-name';
|
||
name.textContent = text(item.name || code);
|
||
button.appendChild(icon);
|
||
button.appendChild(name);
|
||
grid.appendChild(button);
|
||
});
|
||
}
|
||
|
||
function formatCoin(value) {
|
||
try {
|
||
return new Intl.NumberFormat(locale()).format(number(value));
|
||
} catch (_) {
|
||
return text(number(value));
|
||
}
|
||
}
|
||
|
||
function formatDuration(durationMS) {
|
||
var days = Math.max(0, Math.round(number(durationMS) / 86400000));
|
||
return interpolate(t('fami_vip.days', '{count} Days'), {
|
||
count: formatCoin(days),
|
||
});
|
||
}
|
||
|
||
function formatExpiry(timestampMS) {
|
||
if (!number(timestampMS)) return t('fami_vip.no_expiry', 'No expiry');
|
||
try {
|
||
return new Intl.DateTimeFormat(locale(), {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
}).format(new Date(number(timestampMS)));
|
||
} catch (_) {
|
||
return new Date(number(timestampMS)).toLocaleString();
|
||
}
|
||
}
|
||
|
||
function sameEffectiveLevel(item) {
|
||
var vip = state.vipState.effective_vip || {};
|
||
return !!vip.active && number(vip.level) === number(item && item.level);
|
||
}
|
||
|
||
function paidVip() {
|
||
return state.vipState.paid_vip || {};
|
||
}
|
||
|
||
function purchaseLabel(item) {
|
||
var paid = paidVip();
|
||
if (paid.active && number(paid.level) === number(item.level)) {
|
||
return t('fami_vip.renew_vip', 'Renew VIP');
|
||
}
|
||
if (paid.active && number(paid.level) < number(item.level)) {
|
||
return t('fami_vip.upgrade_vip', 'Upgrade VIP');
|
||
}
|
||
return t('fami_vip.activate_vip', 'Activate VIP');
|
||
}
|
||
|
||
function renderBottomBar() {
|
||
var item = selectedPackage();
|
||
var priceState = byId('priceState');
|
||
var activeState = byId('activeState');
|
||
var priceText = byId('priceText');
|
||
var activeLevelText = byId('activeLevelText');
|
||
var expiryText = byId('expiryText');
|
||
var purchaseButton = byId('purchaseButton');
|
||
var purchaseText = byId('purchaseText');
|
||
if (!purchaseButton || !purchaseText) return;
|
||
|
||
if (!item) {
|
||
priceState.hidden = false;
|
||
activeState.hidden = true;
|
||
priceText.textContent = '--';
|
||
purchaseText.textContent = t('fami_vip.unavailable', 'Unavailable');
|
||
purchaseButton.disabled = true;
|
||
return;
|
||
}
|
||
|
||
var showActive = sameEffectiveLevel(item);
|
||
priceState.hidden = showActive;
|
||
activeState.hidden = !showActive;
|
||
if (showActive) {
|
||
var effective = state.vipState.effective_vip || {};
|
||
var source = text(state.vipState.effective_source);
|
||
activeLevelText.textContent = interpolate(
|
||
source === 'trial'
|
||
? t('fami_vip.trial_level', 'Trial Level: {level}')
|
||
: t('fami_vip.unlocked_level', 'Unlocked Level: {level}'),
|
||
{
|
||
level: text(
|
||
effective.name || item.name || 'VIP' + item.level
|
||
),
|
||
}
|
||
);
|
||
expiryText.textContent = interpolate(
|
||
t('fami_vip.valid_until', 'Valid until: {date}'),
|
||
{ date: formatExpiry(effective.expires_at_ms) }
|
||
);
|
||
} else {
|
||
priceText.textContent =
|
||
formatCoin(item.price_coin) +
|
||
' / ' +
|
||
formatDuration(item.duration_ms);
|
||
}
|
||
|
||
var canPurchase = item.can_purchase === true;
|
||
purchaseText.textContent = state.purchasing
|
||
? t('fami_vip.processing', 'Processing...')
|
||
: canPurchase
|
||
? purchaseLabel(item)
|
||
: t('fami_vip.unavailable', 'Unavailable');
|
||
purchaseButton.disabled =
|
||
state.pageState !== 'ready' || state.purchasing || !canPurchase;
|
||
purchaseButton.setAttribute(
|
||
'aria-label',
|
||
canPurchase
|
||
? purchaseLabel(item)
|
||
: lockedReason(item.purchase_locked_reason)
|
||
);
|
||
}
|
||
|
||
function lockedReason(reason) {
|
||
if (text(reason) === 'current_vip_higher') {
|
||
return t(
|
||
'fami_vip.current_vip_higher',
|
||
'A lower VIP level cannot be purchased while a higher level is active.'
|
||
);
|
||
}
|
||
return t('fami_vip.unavailable', 'Unavailable');
|
||
}
|
||
|
||
function commandID(level) {
|
||
var random = '';
|
||
if (window.crypto && window.crypto.getRandomValues) {
|
||
var bytes = new Uint32Array(2);
|
||
window.crypto.getRandomValues(bytes);
|
||
random = bytes[0].toString(36) + bytes[1].toString(36);
|
||
} else {
|
||
random = Math.random().toString(36).slice(2);
|
||
}
|
||
return (
|
||
'fami_vip_' +
|
||
number(level) +
|
||
'_' +
|
||
Date.now().toString(36) +
|
||
'_' +
|
||
random
|
||
).slice(0, 128);
|
||
}
|
||
|
||
function purchaseSelected() {
|
||
var item = selectedPackage();
|
||
if (
|
||
!item ||
|
||
item.can_purchase !== true ||
|
||
state.purchasing ||
|
||
!window.HyAppAPI ||
|
||
!window.HyAppAPI.vip
|
||
) {
|
||
if (item && item.purchase_locked_reason) {
|
||
toast(lockedReason(item.purchase_locked_reason));
|
||
}
|
||
return;
|
||
}
|
||
state.purchasing = true;
|
||
renderBottomBar();
|
||
var previousState = state.vipState;
|
||
window.HyAppAPI.vip
|
||
.purchase({
|
||
command_id: commandID(item.level),
|
||
level: number(item.level),
|
||
})
|
||
.then(function (response) {
|
||
if (!response || !response.state) {
|
||
throw new Error('vip_purchase_state_missing');
|
||
}
|
||
// PurchaseVip 的 state 是扣费、会员和权益在同一事务后的完整回包。
|
||
// 只有完整 state 存在才一次性替换,绝不先改等级再等待第二个请求补权益。
|
||
state.vipState = normalizeVipState(response.state);
|
||
state.programConfig =
|
||
response.program_config ||
|
||
state.vipState.program_config ||
|
||
state.programConfig;
|
||
renderAll();
|
||
toast(t('fami_vip.purchase_success', 'Purchase successful.'));
|
||
// 购买响应没有更新后的套餐 can_purchase;后台刷新失败时仍保留已原子提交的新 state,
|
||
// 但不会把失败刷新产生的半成品覆盖到页面。
|
||
return loadData(0).catch(function () {
|
||
return undefined;
|
||
});
|
||
})
|
||
.catch(function (error) {
|
||
state.vipState = previousState;
|
||
toast(
|
||
(error && error.message) ||
|
||
t('fami_vip.purchase_failed', 'Purchase failed.')
|
||
);
|
||
})
|
||
.finally(function () {
|
||
state.purchasing = false;
|
||
renderAll();
|
||
});
|
||
}
|
||
|
||
function toast(message) {
|
||
if (window.HyAppToast && window.HyAppToast.show) {
|
||
window.HyAppToast.show(message);
|
||
}
|
||
}
|
||
|
||
function openModal(id) {
|
||
var modal = byId(id);
|
||
var scroll = byId('pageScroll');
|
||
if (!modal || !scroll) return;
|
||
state.lastFocused = document.activeElement;
|
||
state.lockedScrollTop = scroll.scrollTop;
|
||
scroll.classList.add('is-locked');
|
||
modal.hidden = false;
|
||
var focusTarget = modal.querySelector('.fami-vip__dialog-close');
|
||
if (focusTarget) focusTarget.focus();
|
||
}
|
||
|
||
function closeModal(id) {
|
||
var modal = byId(id);
|
||
var scroll = byId('pageScroll');
|
||
if (!modal || !scroll) return;
|
||
modal.hidden = true;
|
||
if (id === 'mediaModal') destroyMediaPlayer();
|
||
if (!document.querySelector('.fami-vip__modal:not([hidden])')) {
|
||
scroll.classList.remove('is-locked');
|
||
scroll.scrollTop = state.lockedScrollTop;
|
||
}
|
||
if (state.lastFocused && state.lastFocused.focus) {
|
||
state.lastFocused.focus();
|
||
}
|
||
}
|
||
|
||
function showBenefit(code) {
|
||
var item = benefitItems(selectedPackage()).find(function (benefit) {
|
||
return text(benefit.benefit_code) === text(code);
|
||
});
|
||
if (!item) return;
|
||
var unlocked = !!effectiveBenefitCodes()[text(item.benefit_code)];
|
||
var iconRoot = byId('detailIcon');
|
||
var icon = document.createElement('img');
|
||
icon.src = iconFile(item);
|
||
icon.alt = '';
|
||
icon.setAttribute('aria-hidden', 'true');
|
||
iconRoot.replaceChildren(icon);
|
||
byId('detailTitle').textContent = text(item.name || item.benefit_code);
|
||
var description = text(item.description).trim();
|
||
byId('detailDescription').textContent = description;
|
||
byId('detailDescription').hidden = !description;
|
||
byId('detailState').textContent = unlocked
|
||
? t('fami_vip.unlocked', 'Unlocked')
|
||
: t('fami_vip.locked', 'Locked');
|
||
openModal('detailModal');
|
||
}
|
||
|
||
function showSettings() {
|
||
var settings = state.vipState.user_settings || {};
|
||
byId('roomEntrySetting').checked =
|
||
settings.room_entry_notice_enabled !== false;
|
||
byId('onlineNoticeSetting').checked =
|
||
settings.online_global_notice_enabled !== false;
|
||
openModal('settingsModal');
|
||
}
|
||
|
||
function updateSetting(field, checked) {
|
||
if (
|
||
state.settingsSaving ||
|
||
!window.HyAppAPI ||
|
||
!window.HyAppAPI.vip ||
|
||
!window.HyAppAPI.vip.updateSettings
|
||
) {
|
||
renderSettingsInputs();
|
||
return;
|
||
}
|
||
var previous = Object.assign({}, state.vipState.user_settings || {});
|
||
var payload = {};
|
||
payload[field] = !!checked;
|
||
state.settingsSaving = true;
|
||
setSettingsDisabled(true);
|
||
window.HyAppAPI.vip
|
||
.updateSettings(payload)
|
||
.then(function (response) {
|
||
if (!response || !response.settings) {
|
||
throw new Error('vip_settings_missing');
|
||
}
|
||
state.vipState.user_settings = Object.assign(
|
||
{},
|
||
previous,
|
||
response.settings
|
||
);
|
||
renderSettingsInputs();
|
||
toast(t('fami_vip.settings_saved', 'Settings saved.'));
|
||
})
|
||
.catch(function () {
|
||
// PATCH 失败必须回滚当前开关,不能让 H5 显示一个服务端并未接受的权限偏好。
|
||
state.vipState.user_settings = previous;
|
||
renderSettingsInputs();
|
||
toast(
|
||
t('fami_vip.settings_failed', 'Unable to save settings.')
|
||
);
|
||
})
|
||
.finally(function () {
|
||
state.settingsSaving = false;
|
||
setSettingsDisabled(false);
|
||
});
|
||
}
|
||
|
||
function renderSettingsInputs() {
|
||
var settings = state.vipState.user_settings || {};
|
||
byId('roomEntrySetting').checked =
|
||
settings.room_entry_notice_enabled !== false;
|
||
byId('onlineNoticeSetting').checked =
|
||
settings.online_global_notice_enabled !== false;
|
||
}
|
||
|
||
function setSettingsDisabled(disabled) {
|
||
byId('roomEntrySetting').disabled = disabled;
|
||
byId('onlineNoticeSetting').disabled = disabled;
|
||
}
|
||
|
||
function destroyMediaPlayer() {
|
||
if (state.mediaPlayer && state.mediaPlayer.destroy) {
|
||
state.mediaPlayer.destroy();
|
||
}
|
||
state.mediaPlayer = null;
|
||
var stage = byId('mediaStage');
|
||
if (stage) stage.replaceChildren();
|
||
}
|
||
|
||
function appendMediaImage(stage, source, name) {
|
||
var image = document.createElement('img');
|
||
image.src = source || FALLBACK_OUTFIT;
|
||
image.alt = text(name);
|
||
image.addEventListener(
|
||
'error',
|
||
function () {
|
||
image.src = FALLBACK_OUTFIT;
|
||
},
|
||
{ once: true }
|
||
);
|
||
stage.replaceChildren(image);
|
||
}
|
||
|
||
function showOutfit(index) {
|
||
var item = rewardItems(selectedPackage())[number(index)];
|
||
if (!item) return;
|
||
destroyMediaPlayer();
|
||
var stage = byId('mediaStage');
|
||
var title = text(item.name || t('fami_vip.outfit', 'VIP outfit'));
|
||
byId('mediaTitle').textContent = title;
|
||
var animationURL = text(item.animation_url);
|
||
var fallback = rewardPreview(item) || FALLBACK_OUTFIT;
|
||
openModal('mediaModal');
|
||
if (
|
||
animationURL &&
|
||
window.HyAppEffectPlayer &&
|
||
window.HyAppEffectPlayer.create &&
|
||
window.HyAppEffectPlayer.inferType(animationURL)
|
||
) {
|
||
state.mediaPlayer = window.HyAppEffectPlayer.create(stage, {
|
||
fit: 'contain',
|
||
waitForWindowLoad: false,
|
||
});
|
||
state.mediaPlayer.play(animationURL).catch(function () {
|
||
destroyMediaPlayer();
|
||
appendMediaImage(stage, fallback, title);
|
||
});
|
||
return;
|
||
}
|
||
appendMediaImage(stage, fallback, title);
|
||
}
|
||
|
||
function handleClick(event) {
|
||
var levelButton = event.target.closest('[data-level]');
|
||
if (levelButton) {
|
||
var nextLevel = number(levelButton.dataset.level);
|
||
if (nextLevel && nextLevel !== state.selectedLevel) {
|
||
state.selectedLevel = nextLevel;
|
||
renderAll();
|
||
}
|
||
return;
|
||
}
|
||
var benefit = event.target.closest('[data-benefit-code]');
|
||
if (benefit) {
|
||
showBenefit(benefit.dataset.benefitCode);
|
||
return;
|
||
}
|
||
var outfit = event.target.closest('[data-outfit-index]');
|
||
if (outfit) {
|
||
showOutfit(outfit.dataset.outfitIndex);
|
||
return;
|
||
}
|
||
var close = event.target.closest('[data-modal-close]');
|
||
if (close) closeModal(close.dataset.modalClose);
|
||
}
|
||
|
||
function bindEvents() {
|
||
document.addEventListener('click', handleClick);
|
||
byId('pageScroll').addEventListener('scroll', syncStickyNavigation, {
|
||
passive: true,
|
||
});
|
||
byId('purchaseButton').addEventListener('click', purchaseSelected);
|
||
byId('retryButton').addEventListener('click', hydrate);
|
||
byId('settingsButton').addEventListener('click', showSettings);
|
||
byId('helpButton').addEventListener('click', function () {
|
||
openModal('helpModal');
|
||
});
|
||
byId('backButton').addEventListener('click', function () {
|
||
if (window.HyAppBridge && window.HyAppBridge.back) {
|
||
window.HyAppBridge.back();
|
||
} else if (window.history.length > 1) {
|
||
window.history.back();
|
||
}
|
||
});
|
||
byId('roomEntrySetting').addEventListener('change', function (event) {
|
||
updateSetting('room_entry_notice_enabled', event.target.checked);
|
||
});
|
||
byId('onlineNoticeSetting').addEventListener(
|
||
'change',
|
||
function (event) {
|
||
updateSetting(
|
||
'online_global_notice_enabled',
|
||
event.target.checked
|
||
);
|
||
}
|
||
);
|
||
document.addEventListener('keydown', function (event) {
|
||
if (event.key !== 'Escape') return;
|
||
var modal = document.querySelector(
|
||
'.fami-vip__modal:not([hidden])'
|
||
);
|
||
if (modal) closeModal(modal.id);
|
||
});
|
||
window.addEventListener('resize', syncViewport);
|
||
window.addEventListener('orientationchange', syncViewport);
|
||
window.addEventListener('hyapp:i18n-ready', renderAll);
|
||
}
|
||
|
||
function init() {
|
||
ensureFamiTenant();
|
||
syncViewport();
|
||
bindEvents();
|
||
renderAll();
|
||
// App 配置必须先确定 Fami 租户,再允许 VIP 请求发出;否则异步配置晚到时可能
|
||
// 改写请求头或 API 地址,让同一屏 packages/me 落入不同租户或环境。
|
||
var configReady =
|
||
window.HyAppAPI && window.HyAppAPI.loadAppConfig
|
||
? window.HyAppAPI.loadAppConfig('')
|
||
: Promise.resolve(null);
|
||
configReady
|
||
.catch(function () {
|
||
return null;
|
||
})
|
||
.then(hydrate);
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|