积分收益政策h5

This commit is contained in:
zhx 2026-07-12 17:38:53 +08:00
parent f457803881
commit 94562110bb
34 changed files with 7077 additions and 9 deletions

View File

@ -256,7 +256,20 @@ var default_api = 'https://api.global-interaction.com/';
appCode = String(appCode || 'lalu')
.trim()
.toLowerCase();
return appCode === 'yumi' || appCode === 'aslan' ? appCode : 'lalu';
// App 身份统一保留在公共主题根节点;未定义专属变量的 Fami/Huwaa 会自然继承 :root 浅紫主题,
// 后续增加品牌覆盖时无需再改 API/环境解析逻辑。未知 app 仍回退 Lalu保护旧页面。
return ['lalu', 'fami', 'huwaa', 'yumi', 'aslan'].indexOf(appCode) >= 0
? appCode
: 'lalu';
}
function pageDefaultAppCode() {
// 新的多产品 H5 入口会在 common/api.js 执行前声明页面所属 App。页面级默认值只在 URL
// 未显式携带 app_code 时生效,既避免 Fami 页面因客户端漏参串到 Lalu也不改变历史页面
// “无参数即 Lalu”的兼容约定。该值不从 localStorage 读取,防止前一次访问污染下一产品。
return String(window.HyAppDefaultAppCode || '')
.trim()
.toLowerCase();
}
function requestAppCode() {
@ -265,7 +278,7 @@ var default_api = 'https://api.global-interaction.com/';
.toLowerCase();
// X-App-Code 只表达本次 URL 显式指定的后端租户,不能复用 getAppCode 的内存或 localStorage 结果。
// 这样没有 app_code 参数的入口会稳定落到 lalu避免历史主题缓存把后续普通页面继续打到 yumi/aslan。
return queryAppCode || 'lalu';
return queryAppCode || pageDefaultAppCode() || 'lalu';
}
function applyAppTheme(appCode) {
@ -280,6 +293,7 @@ var default_api = 'https://api.global-interaction.com/';
var queryAppCode =
readQuery('app') || readQuery('app_code') || readQuery('appCode');
if (queryAppCode) return queryAppCode;
if (pageDefaultAppCode()) return pageDefaultAppCode();
if (memoryAppCode) return memoryAppCode;
if (!shouldPersist()) return 'lalu';
return window.localStorage.getItem(APP_CODE_KEY) || 'lalu';
@ -295,6 +309,11 @@ var default_api = 'https://api.global-interaction.com/';
applyAppTheme(queryAppCode);
return queryAppCode;
}
if (pageDefaultAppCode()) {
memoryAppCode = pageDefaultAppCode();
applyAppTheme(memoryAppCode);
return memoryAppCode;
}
if (memoryAppCode) {
applyAppTheme(memoryAppCode);
return memoryAppCode;
@ -632,6 +651,34 @@ var default_api = 'https://api.global-interaction.com/';
},
};
// POINT 通用钱包的汇率、手续费和币商白名单全部由服务端快照返回;页面不持有资金参数默认值。
var pointWalletAPI = {
overview: function () {
return request('/api/v1/point-wallet/overview', {
method: 'GET',
});
},
withdrawUSDT: function (payload) {
return request('/api/v1/point-wallet/withdraw', {
method: 'POST',
body: payload || {},
});
},
transferToCoinSeller: function (payload) {
return request('/api/v1/point-wallet/transfer-to-coin-seller', {
method: 'POST',
body: payload || {},
});
},
history: function (pageSize) {
return walletAPI.transactions({
asset_type: 'POINT',
page: 1,
page_size: pageSize || 30,
});
},
};
// H5 充值 API 只做路径和参数名封装token、app_code、env、错误 envelope 仍由 request 统一处理。
// 金额、金币数、普通/币商钱包、三方支付参数和 USDT 链上校验都以后端订单快照为准。
function isAslanWebPay() {
@ -1438,6 +1485,13 @@ var default_api = 'https://api.global-interaction.com/';
method: 'GET',
});
},
stats: function (query) {
// 公会统计只接受自然日范围;收益、分成和主播在线数据均由 owner service 聚合。
return request('/api/v1/agency-center/stats', {
method: 'GET',
query: query || {},
});
},
applications: function (status) {
return request('/api/v1/agency-center/applications', {
method: 'GET',
@ -1480,6 +1534,13 @@ var default_api = 'https://api.global-interaction.com/';
method: 'GET',
});
},
stats: function (query) {
// Fami 新主播中心按任意自然日区间读取服务端聚合;前端只传日期,不上传时长或收益值。
return request('/api/v1/host-center/stats', {
method: 'GET',
query: query || {},
});
},
platformPolicy: function () {
return request('/api/v1/host-center/platform-policy', {
method: 'GET',
@ -1531,6 +1592,12 @@ var default_api = 'https://api.global-interaction.com/';
query: { page_size: pageSize || 20 },
});
},
stats: function (query) {
return request('/api/v1/bd-center/stats', {
method: 'GET',
query: query || {},
});
},
inviteAgency: function (payload) {
return request('/api/v1/bd/invitations/agency', {
method: 'POST',
@ -2056,6 +2123,7 @@ var default_api = 'https://api.global-interaction.com/';
vip: vipAPI,
user: userAPI,
wallet: walletAPI,
pointWallet: pointWalletAPI,
recharge: rechargeAPI,
salaryWallet: salaryWalletAPI,
host: hostAPI,

View File

@ -109,18 +109,52 @@
return DEFAULT_LANG;
}
function load(lang) {
var normalized = normalizeLang(lang) || DEFAULT_LANG;
function localeSources() {
var sources = [localeBaseURL];
var extras = Array.isArray(window.HyAppI18nExtraSources)
? window.HyAppI18nExtraSources
: [];
extras.forEach(function (source) {
var normalized = String(source || '').trim();
if (!normalized) return;
if (normalized.charAt(normalized.length - 1) !== '/') {
normalized += '/';
}
if (sources.indexOf(normalized) < 0) sources.push(normalized);
});
return sources;
}
function fetchLocale(source, lang) {
return window
.fetch(localeBaseURL + normalized + '.json', {
cache: 'no-store',
})
.fetch(source + lang + '.json', { cache: 'no-store' })
.then(function (response) {
if (!response.ok) throw new Error('locale_not_found');
return response.json();
})
.then(function (json) {
messages = json || {};
.catch(function () {
// 产品语言包是可选叠加层;单个包发布延迟不能让 common 基础文案也一起失效。
return null;
});
}
function load(lang) {
var normalized = normalizeLang(lang) || DEFAULT_LANG;
return Promise.all(
localeSources().map(function (source) {
return fetchLocale(source, normalized);
})
)
.then(function (localeMessages) {
var found = false;
messages = {};
localeMessages.forEach(function (sourceMessages) {
if (!sourceMessages) return;
found = true;
// 产品包后加载并覆盖同名 key允许品牌化文案但 common 仍提供跨页面兜底。
Object.assign(messages, sourceMessages);
});
if (!found) throw new Error('locale_not_found');
currentLang = normalized;
storageSet(STORAGE_KEY, currentLang);
apply();

95
guild/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,95 @@
# Guild H5 architecture
## Decision
The existing `gonghui/` tree is the Lalu legacy product and remains independently deployable. New guild products use a zero-build, static multi-package architecture under `guild/`:
```text
guild/
├── packages/
│ ├── core/ # Product context, date ranges, errors, state machines
│ ├── ui/ # Product-neutral center layouts and components
│ ├── host-center/ # Host feature package (added with the first host screen)
│ ├── agency-center/ # Agency feature package
│ ├── bd-center/ # BD feature package
│ └── wallet/ # Shared coin-seller and USDT withdrawal flow
├── products/
│ └── fami/ # Fami manifest, assets and visual overrides
└── fami/
├── host-center/ # Thin deployable page entry
├── agency-center/
├── bd-center/
└── wallet/
```
Each page entry composes packages with ordered classic `<script>` and `<link>` tags. There is deliberately no bundling step: the current repository is deployed as static files, so a build-system failure must not take down existing H5 pages.
## Package boundaries
| Package | Owns | Must not own |
| -------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------- |
| `common/` | Existing cross-H5 API transport, environment selection, i18n and base theme | Guild role semantics or Fami page state |
| `guild/packages/core` | Product context, shared time range model, request DTO mapping, async state primitives | DOM layout and product colors |
| `guild/packages/ui` | Responsive cards, filters, tables, empty/loading/error presentation | API paths or role permissions |
| `guild/packages/<feature>` | One role/flow's state, API adapter and render controller | Fami-only text, assets or hardcoded app code |
| `guild/products/fami` | `app_code=fami`, feature flags, Fami assets and permitted visual overrides | Shared business calculations |
| `guild/fami/<feature>` | HTML composition and page-only wiring | Reusable business logic |
## Runtime composition
Every Fami page must load scripts in this order:
```html
<script src="../../packages/core/product-context.js"></script>
<script src="../../products/fami/product.js"></script>
<script src="../../../common/api.js"></script>
<script src="../../../common/params.js"></script>
<script src="../../../common/i18n.js"></script>
```
`product.js` runs before `common/api.js`, making Fami the page-level default tenant even when an older client omits `app_code`. An explicit URL `app_code` still wins for diagnostics. Pages under the old `gonghui/` tree load none of these files, so their default remains Lalu.
## Shared domain rules from the initial specification
- Host, agency and BD statistics consume one date-range model: today, yesterday, last 7 days, last 30 days, this month, last month and custom range.
- Metrics are response-driven. The frontend renders values and definitions but does not recompute diamonds, gift senders, online duration, valid live duration/days, private-message users or new followers.
- Agency and BD list rows use stable IDs as keys. Removing a host or agency updates the view only after the server confirms the command.
- Wallet is a separate feature package because host, agency and BD reuse it. Coin-seller withdrawal and USDT withdrawal are separate state machines with separate validation, idempotency command IDs and histories.
- Permissions and feature switches come from backend capabilities. Functional switches default to enabled; only external addresses, secrets and third-party credentials may require environment configuration.
## Backend/admin contract direction
New endpoints should be app-scoped through `X-App-Code: fami` and return server-calculated metrics. Prefer additive `/api/v1/guild/...` contracts or additive fields on existing center endpoints; do not change Lalu response meaning in place.
The admin coin-seller withdrawal whitelist belongs to backend/admin storage. The H5 only consumes eligible coin sellers and must never treat a locally cached list as authorization.
### Implemented host statistics contract
`GET /api/v1/host-center/stats?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD` returns an inclusive UTC natural-day range and these server-owned metrics:
- `diamond_earnings`, `diamond_exchanged`, `gift_senders` from wallet-service POINT ledger entries;
- `online_duration_ms` from App session heartbeat spans, merged per day so overlapping sessions are not double-counted;
- `valid_mic_duration_ms`, `valid_mic_days` from user-service microphone daily facts;
- `private_message_senders` from authenticated Tencent IM `C2C.CallbackAfterSendMsg` events;
- `new_followers` from follow/unfollow delta events.
The Tencent C2C callback URL must include the matching product context, for example `app_code=fami`. The callback stores only event ID, sender, receiver and event time; message content is never written to the statistics table.
### Implemented Agency contract and revenue policy
`GET /api/v1/agency-center/stats?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD` returns the Agency summary and current host rows for the same inclusive UTC date range. Host earnings come from wallet POINT entries; engagement metrics come from user-service facts.
Agency share is configured in the shared Admin revenue-policy template JSON as `agency.point_ratio_percent`. The common legacy template defaults to `0`; the seeded `fami_guild_revenue_policy` template defaults to `20`. Publishing an App policy compiles the value into `wallet_policy_instances.agency_point_ratio_ppm`. Each successful gift atomically credits the owner POINT account and writes `agency_point_share_entries`, including the host, owner, ratio and policy instance snapshots. Historical earnings therefore do not move when a host later changes Agency.
### Implemented BD and wallet contracts
`GET /api/v1/bd-center/stats` aggregates the current BD's direct Agencies, active host counts and host gift POINT income without including Agency-share POINT. The page reuses the same date-range model and invitation acceptance flow as the existing organization domain.
The Fami wallet is a shared `guild/packages/wallet` feature mounted at `guild/fami/wallet/`. `GET /api/v1/point-wallet/overview` returns the POINT balance, published policy exchange/fee snapshot and the country-filtered coin-seller whitelist. POINT-to-seller transfers never accept an exchange ratio from H5: wallet-service locks `point_withdrawal_coin_seller_configs` and atomically debits `POINT` while crediting `COIN_SELLER_COIN`. Admin edits the same app-scoped table from `/host/point-withdrawal-config`; switching the selected App supports Fami, Huwaa, Lalu and future products without adding boolean columns.
## Adding another guild product
1. Add `guild/products/<product>/product.js` and optional assets/theme overrides.
2. Add thin entries under `guild/<product>/<feature>/`.
3. Reuse a feature package when its API/state semantics match; add a product adapter when DTOs differ.
4. Run `npm run test:guild-architecture` to prove the new default app code does not change legacy Lalu requests.

12
guild/fami/README.md Normal file
View File

@ -0,0 +1,12 @@
# Fami guild page entries
This product will expose four independent static entries:
- `host-center/`
- `agency-center/`
- `bd-center/`
- `wallet/`
Entries are intentionally thin. Shared business logic belongs in `guild/packages/<feature>/`; Fami-specific configuration and visual overrides belong in `guild/products/fami/`.
Do not copy scripts from `gonghui/` wholesale. Reuse existing backend semantics through a feature adapter and keep any Fami response differences additive and explicit.

View File

@ -0,0 +1,422 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>Agency Center</title>
<link rel="stylesheet" href="../../../common/theme.css" />
<link rel="stylesheet" href="../../packages/ui/center.css" />
<link rel="stylesheet" href="./style.css" />
<script src="../../packages/core/product-context.js"></script>
<script src="../../products/fami/product.js"></script>
</head>
<body>
<div class="guild-center fami-agency-center">
<header class="center-header">
<button
class="icon-button"
id="backButton"
type="button"
data-i18n-aria="fami_common.back"
aria-label="Back"
>
<span aria-hidden="true"></span>
</button>
<h1 data-i18n="fami_agency.title">Agency Center</h1>
<div class="language-switcher">
<button
class="language-button"
type="button"
data-language-toggle
data-current-lang
aria-expanded="false"
data-i18n-aria="fami_common.change_language"
aria-label="Change language"
>
EN
</button>
<div class="language-menu" data-language-menu hidden>
<button type="button" data-lang-option="en">EN</button>
<button type="button" data-lang-option="ar">AR</button>
<button type="button" data-lang-option="tr">TR</button>
<button type="button" data-lang-option="es">ES</button>
</div>
</div>
</header>
<main class="guild-center__content" aria-live="polite">
<section class="guild-card agency-profile-card">
<div class="agency-avatar">
<img id="agencyAvatar" alt="" hidden />
<span id="agencyInitial" aria-hidden="true">A</span>
</div>
<div class="agency-profile-copy">
<strong id="agencyName"></strong>
<span id="agencyID">Agency ID: —</span>
<span id="createdAt">Created: —</span>
</div>
</section>
<section class="guild-card contact-card">
<span class="contact-icon" aria-hidden="true"></span>
<div>
<strong data-i18n="fami_agency.contact">WhatsApp</strong
><span id="contactValue"></span>
</div>
<button
id="editContactButton"
type="button"
data-i18n="fami_common.edit"
>
Edit
</button>
</section>
<section class="guild-card balance-card">
<div>
<span
class="card-label"
data-i18n="fami_common.diamond_balance"
>Diamond balance</span
>
<strong class="balance-value"
><span aria-hidden="true">💎</span
><span id="diamondBalance"></span></strong
>
</div>
<button
class="primary-action"
id="withdrawButton"
type="button"
data-i18n="fami_common.withdraw"
>
Withdraw
</button>
</section>
<section class="data-section" aria-labelledby="dataTitle">
<div class="section-title-row">
<h2 id="dataTitle" data-i18n="fami_agency.data_title">
Agency data
</h2>
<span id="rangeCaption"></span>
</div>
<div class="data-tabs" role="tablist">
<button
type="button"
role="tab"
data-tab="agency"
aria-selected="true"
data-i18n="fami_agency.tab_agency"
>
Agency data
</button>
<button
type="button"
role="tab"
data-tab="hosts"
aria-selected="false"
data-i18n="fami_agency.tab_hosts"
>
Host data
</button>
</div>
<div
class="guild-filter-bar"
id="rangeFilters"
role="group"
data-i18n-aria="fami_range.label"
aria-label="Date range"
>
<button
type="button"
data-range="today"
data-i18n="fami_range.today"
>
Today
</button>
<button
type="button"
data-range="yesterday"
data-i18n="fami_range.yesterday"
>
Yesterday
</button>
<button
type="button"
data-range="last_7_days"
data-i18n="fami_range.last_7_days"
>
Last 7 days
</button>
<button
type="button"
data-range="last_30_days"
data-i18n="fami_range.last_30_days"
>
Last 30 days
</button>
<button
type="button"
data-range="this_month"
data-i18n="fami_range.this_month"
>
This month
</button>
<button
type="button"
data-range="last_month"
data-i18n="fami_range.last_month"
>
Last month
</button>
<button
type="button"
data-range="custom"
data-i18n="fami_range.custom"
>
Custom
</button>
</div>
<div class="stats-status" id="statsStatus" hidden></div>
<div id="agencyPanel" role="tabpanel">
<div class="guild-card summary-grid">
<article>
<strong id="totalEarnings"></strong
><span data-i18n="fami_agency.total_earnings"
>Total earnings</span
>
</article>
<article>
<strong id="giftIncome"></strong
><span data-i18n="fami_agency.gift_income"
>Gift income</span
>
</article>
<article>
<strong id="shareIncome"></strong
><span data-i18n="fami_agency.share_income"
>Share income</span
>
</article>
<article>
<strong id="totalHosts"></strong
><span data-i18n="fami_agency.total_hosts"
>Total hosts</span
>
</article>
<article>
<strong id="giftedHosts"></strong
><span data-i18n="fami_agency.gifted_hosts"
>Gifted hosts</span
>
</article>
</div>
</div>
<div id="hostsPanel" role="tabpanel" hidden>
<div class="host-panel-toolbar">
<span id="hostCountLabel"></span>
<button
class="primary-action compact"
id="addHostButton"
type="button"
>
<span aria-hidden="true"></span
><span data-i18n="fami_agency.add_host"
>Add host</span
>
</button>
</div>
<div class="guild-card host-table-shell">
<table class="host-table">
<thead>
<tr>
<th data-i18n="fami_agency.host">
Host
</th>
<th
data-i18n="fami_metric.diamond_earnings"
>
Diamond earnings
</th>
<th
data-i18n="fami_metric.diamond_exchanged"
>
Diamonds exchanged
</th>
<th
data-i18n="fami_metric.gift_senders"
>
Gift senders
</th>
<th
data-i18n="fami_metric.online_duration"
>
Online duration
</th>
<th
data-i18n="fami_metric.valid_mic_duration"
>
Valid mic duration
</th>
<th
data-i18n="fami_metric.valid_mic_days"
>
Valid mic days
</th>
<th data-i18n="fami_agency.joined_at">
Joined
</th>
<th data-i18n="fami_agency.action">
Action
</th>
</tr>
</thead>
<tbody id="hostTableBody"></tbody>
</table>
<div
class="empty-state"
id="hostEmpty"
hidden
data-i18n="fami_agency.no_hosts"
>
No hosts yet.
</div>
</div>
</div>
</section>
</main>
<dialog class="edit-dialog" id="contactDialog">
<form method="dialog" id="contactForm">
<h2 data-i18n="fami_agency.edit_contact">Edit WhatsApp</h2>
<label
><span data-i18n="fami_agency.contact_number"
>Contact number</span
><input
id="contactInput"
type="text"
maxlength="64"
autocomplete="tel"
/></label>
<div class="dialog-actions">
<button
type="button"
id="cancelContactButton"
data-i18n="fami_common.cancel"
>
Cancel</button
><button
class="primary-action"
type="submit"
data-i18n="fami_common.save"
>
Save
</button>
</div>
</form>
</dialog>
<dialog class="edit-dialog" id="rangeDialog">
<form method="dialog" id="rangeForm">
<h2 data-i18n="fami_range.custom_title">
Custom date range
</h2>
<label
><span data-i18n="fami_range.start">Start date</span
><input id="rangeStart" type="date" required
/></label>
<label
><span data-i18n="fami_range.end">End date</span
><input id="rangeEnd" type="date" required
/></label>
<div class="dialog-actions">
<button
type="button"
id="cancelRangeButton"
data-i18n="fami_common.cancel"
>
Cancel</button
><button
class="primary-action"
type="submit"
data-i18n="fami_common.apply"
>
Apply
</button>
</div>
</form>
</dialog>
<dialog class="edit-dialog" id="inviteDialog">
<form method="dialog" id="inviteForm">
<h2 data-i18n="fami_agency.add_host">Add host</h2>
<p data-i18n="fami_agency.invite_hint">
Enter the host display ID. The host joins after
accepting the invitation.
</p>
<label
><span data-i18n="fami_agency.host_id">Host ID</span
><input
id="inviteHostID"
type="text"
inputmode="numeric"
maxlength="32"
required
/></label>
<div class="dialog-actions">
<button
type="button"
id="cancelInviteButton"
data-i18n="fami_common.cancel"
>
Cancel</button
><button
class="primary-action"
type="submit"
data-i18n="fami_agency.send_invite"
>
Send invite
</button>
</div>
</form>
</dialog>
<dialog class="edit-dialog" id="removeDialog">
<form method="dialog" id="removeForm">
<h2 data-i18n="fami_agency.remove_host">Remove host</h2>
<p id="removeMessage"></p>
<div class="dialog-actions">
<button
type="button"
id="cancelRemoveButton"
data-i18n="fami_common.cancel"
>
Cancel</button
><button
class="danger-action"
type="submit"
data-i18n="fami_agency.remove"
>
Remove
</button>
</div>
</form>
</dialog>
</div>
<script src="../../../common/api.js"></script>
<script src="../../../common/params.js"></script>
<script src="../../../common/i18n.js"></script>
<script src="../../../common/jsbridge.js"></script>
<script src="../../../common/toast.js"></script>
<script src="../../packages/core/date-range.js"></script>
<script src="../../packages/agency-center/agency-center.js"></script>
<script src="./script.js"></script>
</body>
</html>

View File

@ -0,0 +1,437 @@
(function () {
'use strict';
var state = {
range: null,
data: null,
tab: 'agency',
loading: false,
mutating: false,
removingHost: null,
};
function $(id) {
return document.getElementById(id);
}
function t(key, fallback) {
return window.HyAppI18n && window.HyAppI18n.t
? window.HyAppI18n.t(key, fallback)
: fallback || key;
}
function toast(message) {
if (window.HyAppToast) window.HyAppToast.show(message);
}
function count(value) {
if (value === null || value === undefined || !Number.isFinite(value)) {
return '—';
}
return Number(value).toLocaleString(undefined, {
maximumFractionDigits: 0,
});
}
function duration(value) {
if (!Number.isFinite(value)) return '—';
var minutes = Math.max(0, Math.floor(Number(value) / 60000));
var hours = Math.floor(minutes / 60);
var rest = minutes % 60;
if (!hours) return rest + t('fami_unit.minute_short', 'm');
return (
hours +
t('fami_unit.hour_short', 'h') +
(rest ? ' ' + rest + t('fami_unit.minute_short', 'm') : '')
);
}
function dateTime(value) {
if (!Number(value)) return '—';
return new Date(Number(value)).toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function renderIdentity() {
if (!state.data) return;
var agency = state.data.agency || {};
$('agencyName').textContent = agency.name || '—';
$('agencyID').textContent =
t('fami_agency.id_prefix', 'Agency ID:') +
' ' +
(agency.shortId || agency.id || '—');
$('createdAt').textContent =
t('fami_agency.created_prefix', 'Created:') +
' ' +
dateTime(agency.createdAtMS);
$('agencyInitial').textContent = String(agency.name || 'A')
.trim()
.charAt(0)
.toUpperCase();
$('agencyAvatar').hidden = !agency.avatar;
$('agencyInitial').hidden = !!agency.avatar;
if (agency.avatar) $('agencyAvatar').src = agency.avatar;
$('contactValue').textContent = state.data.contact || '—';
$('diamondBalance').textContent = count(state.data.pointBalance);
}
function renderSummary() {
var summary =
state.data && state.data.stats
? state.data.stats.summary || {}
: {};
$('totalEarnings').textContent = count(summary.total_earnings);
$('giftIncome').textContent = count(summary.gift_income);
$('shareIncome').textContent = count(summary.share_income);
$('totalHosts').textContent = count(summary.total_hosts);
$('giftedHosts').textContent = count(summary.gifted_host_count);
var statsError = state.data && state.data.errors.stats;
$('statsStatus').hidden = !statsError;
if (statsError) {
$('statsStatus').textContent = t(
'fami_common.stats_unavailable',
'Statistics are temporarily unavailable.'
);
}
}
function hostAvatar(host) {
var shell = document.createElement('span');
shell.className = 'host-avatar';
if (host.avatar) {
var image = document.createElement('img');
image.src = host.avatar;
image.alt = '';
shell.appendChild(image);
} else {
shell.textContent = String(host.name || 'H')
.charAt(0)
.toUpperCase();
}
return shell;
}
function textCell(row, value) {
var cell = document.createElement('td');
cell.textContent = value;
row.appendChild(cell);
}
function renderHosts() {
var hosts =
state.data && state.data.stats ? state.data.stats.hosts || [] : [];
var body = $('hostTableBody');
body.textContent = '';
hosts.forEach(function (host) {
var row = document.createElement('tr');
var identityCell = document.createElement('td');
var identity = document.createElement('div');
identity.className = 'host-identity';
identity.appendChild(hostAvatar(host));
var copy = document.createElement('span');
copy.className = 'host-copy';
var name = document.createElement('strong');
name.textContent = host.name || '—';
var id = document.createElement('span');
id.textContent =
t('fami_common.uid_prefix', 'UID:') +
' ' +
(host.displayUserId || host.userId || '—');
copy.appendChild(name);
copy.appendChild(id);
identity.appendChild(copy);
identityCell.appendChild(identity);
row.appendChild(identityCell);
textCell(row, count(host.diamondEarnings));
textCell(row, count(host.diamondExchanged));
textCell(row, count(host.giftSenders));
textCell(row, duration(host.onlineDurationMS));
textCell(row, duration(host.validMicDurationMS));
textCell(row, count(host.validMicDays));
textCell(row, dateTime(host.joinedAtMS));
var actionCell = document.createElement('td');
var remove = document.createElement('button');
remove.type = 'button';
remove.className = 'remove-button';
if (host.removable) {
remove.setAttribute('data-remove-host', host.userId);
remove.textContent = t('fami_agency.remove', 'Remove');
} else {
remove.disabled = true;
remove.textContent = t('fami_agency.owner', 'Owner');
}
actionCell.appendChild(remove);
row.appendChild(actionCell);
body.appendChild(row);
});
$('hostEmpty').hidden = hosts.length > 0;
$('hostCountLabel').textContent = t(
'fami_agency.host_count',
'{count} hosts'
).replace('{count}', count(hosts.length));
}
function renderRange() {
document.querySelectorAll('[data-range]').forEach(function (button) {
button.setAttribute(
'aria-pressed',
button.getAttribute('data-range') === state.range.preset
? 'true'
: 'false'
);
button.disabled = state.loading;
});
$('rangeCaption').textContent =
state.range.startDate === state.range.endDate
? state.range.startDate
: state.range.startDate + ' ' + state.range.endDate;
}
function renderTabs() {
document.querySelectorAll('[data-tab]').forEach(function (button) {
button.setAttribute(
'aria-selected',
button.getAttribute('data-tab') === state.tab ? 'true' : 'false'
);
});
$('agencyPanel').hidden = state.tab !== 'agency';
$('hostsPanel').hidden = state.tab !== 'hosts';
$('dataTitle').textContent =
state.tab === 'agency'
? t('fami_agency.tab_agency', 'Agency data')
: t('fami_agency.tab_hosts', 'Host data');
}
function renderAll() {
renderIdentity();
renderSummary();
renderHosts();
renderRange();
renderTabs();
}
function setLoading(value) {
state.loading = value;
document.body.classList.toggle('is-loading', value);
renderRange();
}
function setMutating(value) {
state.mutating = value;
document.body.classList.toggle('is-mutating', value);
}
function load() {
setLoading(true);
return window.HyGuild.agencyCenter
.load(state.range)
.then(function (data) {
state.data = data;
renderAll();
})
.catch(function () {
toast(
t(
'fami_agency.load_failed',
'Unable to load Agency Center.'
)
);
$('statsStatus').hidden = false;
$('statsStatus').textContent = t(
'fami_agency.load_failed',
'Unable to load Agency Center.'
);
})
.finally(function () {
setLoading(false);
});
}
function selectRange(preset) {
if (preset === 'custom') {
$('rangeStart').value = state.range.startDate;
$('rangeEnd').value = state.range.endDate;
$('rangeDialog').showModal();
return;
}
state.range = window.HyGuild.dateRange.create(preset);
renderRange();
load();
}
function applyCustomRange(event) {
event.preventDefault();
try {
state.range = window.HyGuild.dateRange.create('custom', {
start: $('rangeStart').value,
end: $('rangeEnd').value,
});
} catch (_) {
toast(t('fami_range.invalid', 'Choose a valid date range.'));
return;
}
$('rangeDialog').close();
load();
}
function saveContact(event) {
event.preventDefault();
if (state.mutating) return;
var value = String($('contactInput').value || '').trim();
if (!value) {
toast(t('fami_agency.contact_required', 'Enter a contact number.'));
return;
}
setMutating(true);
window.HyGuild.agencyCenter
.saveContact(value)
.then(function () {
state.data.contact = value;
$('contactDialog').close();
renderIdentity();
toast(t('fami_agency.contact_saved', 'Contact saved.'));
})
.catch(function () {
toast(
t(
'fami_agency.contact_save_failed',
'Unable to save contact.'
)
);
})
.finally(function () {
setMutating(false);
});
}
function inviteHost(event) {
event.preventDefault();
if (state.mutating) return;
var displayID = String($('inviteHostID').value || '').trim();
if (!displayID) return;
setMutating(true);
window.HyGuild.agencyCenter
.inviteHost(displayID)
.then(function () {
$('inviteDialog').close();
toast(
t(
'fami_agency.invite_sent',
'Invitation sent. The host must accept it.'
)
);
})
.catch(function () {
toast(t('fami_agency.invite_failed', 'Unable to invite host.'));
})
.finally(function () {
setMutating(false);
});
}
function openRemove(hostUserID) {
var hosts = state.data.stats.hosts || [];
state.removingHost = hosts.find(function (host) {
return host.userId === hostUserID;
});
if (!state.removingHost) return;
$('removeMessage').textContent = t(
'fami_agency.remove_confirm',
'Remove {name} from this agency? Historical earnings will remain unchanged.'
).replace('{name}', state.removingHost.name || hostUserID);
$('removeDialog').showModal();
}
function removeHost(event) {
event.preventDefault();
if (state.mutating || !state.removingHost) return;
var target = state.removingHost;
setMutating(true);
window.HyGuild.agencyCenter
.removeHost(target.userId)
.then(function () {
// 成员关系由后端原子结束;成功后重拉统计,不能只在本地删行伪造 total_hosts。
$('removeDialog').close();
state.removingHost = null;
toast(t('fami_agency.host_removed', 'Host removed.'));
return load();
})
.catch(function () {
toast(t('fami_agency.remove_failed', 'Unable to remove host.'));
})
.finally(function () {
setMutating(false);
});
}
function bind() {
$('backButton').addEventListener('click', function () {
if (window.history.length > 1) window.history.back();
else if (window.HyAppBridge) window.HyAppBridge.back();
});
$('withdrawButton').addEventListener('click', function () {
window.location.href =
window.HyGuild.product.buildURL('../wallet/');
});
$('editContactButton').addEventListener('click', function () {
$('contactInput').value = (state.data && state.data.contact) || '';
$('contactDialog').showModal();
});
$('cancelContactButton').addEventListener('click', function () {
$('contactDialog').close();
});
$('contactForm').addEventListener('submit', saveContact);
document
.querySelector('.data-tabs')
.addEventListener('click', function (event) {
var button = event.target.closest('[data-tab]');
if (!button) return;
state.tab = button.getAttribute('data-tab');
renderTabs();
});
$('rangeFilters').addEventListener('click', function (event) {
var button = event.target.closest('[data-range]');
if (button && !state.loading)
selectRange(button.getAttribute('data-range'));
});
$('cancelRangeButton').addEventListener('click', function () {
$('rangeDialog').close();
});
$('rangeForm').addEventListener('submit', applyCustomRange);
$('addHostButton').addEventListener('click', function () {
$('inviteHostID').value = '';
$('inviteDialog').showModal();
});
$('cancelInviteButton').addEventListener('click', function () {
$('inviteDialog').close();
});
$('inviteForm').addEventListener('submit', inviteHost);
$('hostTableBody').addEventListener('click', function (event) {
var button = event.target.closest('[data-remove-host]');
if (button) openRemove(button.getAttribute('data-remove-host'));
});
$('cancelRemoveButton').addEventListener('click', function () {
$('removeDialog').close();
});
$('removeForm').addEventListener('submit', removeHost);
window.addEventListener('hyapp:i18n-ready', renderAll);
}
function init() {
state.range = window.HyGuild.dateRange.create('today');
bind();
renderRange();
load();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@ -0,0 +1,566 @@
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
background: var(--hy-theme-bg);
color: var(--hy-theme-text);
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
.center-header {
position: sticky;
z-index: 20;
top: 0;
display: grid;
grid-template-columns: 48px 1fr 48px;
align-items: center;
min-height: calc(64px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 12px 0;
border-bottom: 1px solid var(--hy-theme-line);
background: color-mix(in srgb, var(--hy-theme-bg) 94%, transparent);
backdrop-filter: blur(16px);
}
.center-header h1 {
margin: 0;
font-size: 22px;
text-align: center;
}
.icon-button,
.language-button {
display: grid;
width: 44px;
height: 40px;
place-items: center;
border: 0;
border-radius: var(--hy-theme-radius-language);
background: var(--hy-theme-surface);
color: var(--hy-theme-text);
box-shadow: var(--hy-theme-shadow);
}
.icon-button span {
font-size: 38px;
font-weight: 300;
line-height: 30px;
}
.language-switcher {
position: relative;
}
.language-button {
font-size: 13px;
font-weight: 800;
}
.language-menu {
position: absolute;
top: 46px;
right: 0;
overflow: hidden;
width: 64px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-small);
background: var(--hy-theme-surface);
box-shadow: var(--hy-theme-modal-shadow);
}
[dir='rtl'] .language-menu {
right: auto;
left: 0;
}
.language-menu button {
width: 100%;
padding: 10px;
border: 0;
background: transparent;
color: var(--hy-theme-text);
}
.language-menu button.is-active {
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
}
.agency-profile-card,
.contact-card,
.balance-card {
display: flex;
align-items: center;
padding: 18px;
}
.agency-avatar {
display: grid;
flex: 0 0 76px;
overflow: hidden;
width: 76px;
height: 76px;
place-items: center;
border: 3px solid var(--hy-theme-primary);
border-radius: 50%;
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
font-size: 30px;
font-weight: 800;
}
.agency-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.agency-profile-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
margin-inline-start: 16px;
}
.agency-profile-copy strong {
overflow: hidden;
font-size: 21px;
text-overflow: ellipsis;
white-space: nowrap;
}
.agency-profile-copy span {
color: var(--hy-theme-muted);
font-size: 13px;
}
.contact-card {
gap: 14px;
}
.contact-icon {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border-radius: 50%;
background: color-mix(
in srgb,
var(--hy-theme-success) 13%,
var(--hy-theme-surface)
);
color: var(--hy-theme-success);
}
.contact-card > div {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 3px;
}
.contact-card > div span {
overflow: hidden;
color: var(--hy-theme-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-card > button {
border: 0;
background: transparent;
color: var(--hy-theme-primary-deep);
font-weight: 750;
}
.balance-card {
justify-content: space-between;
gap: 18px;
}
.balance-card > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
}
.card-label,
.section-title-row span {
color: var(--hy-theme-muted);
font-size: 13px;
}
.balance-value {
display: flex;
align-items: center;
gap: 8px;
overflow-wrap: anywhere;
font-size: clamp(20px, 6vw, 30px);
}
.primary-action,
.danger-action {
min-width: 94px;
min-height: 42px;
padding: 0 18px;
border: 0;
border-radius: var(--hy-theme-radius-pill);
color: #fff;
font-weight: 750;
}
.primary-action {
background: var(--hy-theme-button);
box-shadow: var(--hy-theme-button-shadow);
}
.danger-action {
background: var(--hy-theme-danger);
}
.primary-action.compact {
display: inline-flex;
min-width: 0;
min-height: 38px;
align-items: center;
gap: 5px;
padding: 0 14px;
font-size: 13px;
}
.section-title-row,
.host-panel-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-title-row {
margin-bottom: 12px;
}
.section-title-row h2 {
margin: 0;
font-size: 19px;
}
.data-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
margin-bottom: 12px;
border-bottom: 1px solid var(--hy-theme-line);
}
.data-tabs button {
position: relative;
min-height: 44px;
border: 0;
background: transparent;
color: var(--hy-theme-muted);
font-weight: 750;
}
.data-tabs button[aria-selected='true'] {
color: var(--hy-theme-primary-deep);
}
.data-tabs button[aria-selected='true']::after {
position: absolute;
right: 18%;
bottom: -1px;
left: 18%;
height: 3px;
border-radius: var(--hy-theme-radius-pill);
background: var(--hy-theme-primary-strong);
content: '';
}
.guild-filter-bar {
margin-bottom: 12px;
}
.guild-filter-bar button {
padding: 5px 4px;
font-size: 11px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: hidden;
}
.summary-grid article {
display: flex;
min-height: 108px;
align-items: center;
justify-content: center;
padding: 14px 8px;
border-right: 1px solid var(--hy-theme-line);
border-bottom: 1px solid var(--hy-theme-line);
flex-direction: column;
gap: 8px;
text-align: center;
}
.summary-grid article:nth-child(3n) {
border-inline-end: 0;
}
.summary-grid article:nth-last-child(-n + 2) {
border-bottom: 0;
}
.summary-grid article strong {
color: var(--hy-theme-primary-deep);
font-size: clamp(18px, 5vw, 25px);
}
.summary-grid article span {
color: var(--hy-theme-muted);
font-size: 12px;
}
.host-panel-toolbar {
margin-bottom: 10px;
color: var(--hy-theme-muted);
font-size: 13px;
}
.host-table-shell {
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
.data-section,
#hostsPanel {
min-width: 0;
max-width: 100%;
}
.host-table {
width: 100%;
min-width: 1060px;
border-collapse: collapse;
font-size: 12px;
}
.host-table th,
.host-table td {
padding: 13px 12px;
border-bottom: 1px solid var(--hy-theme-line);
text-align: start;
white-space: nowrap;
}
.host-table th {
color: var(--hy-theme-muted);
font-size: 11px;
font-weight: 750;
}
.host-table tr:last-child td {
border-bottom: 0;
}
.host-identity {
display: flex;
align-items: center;
gap: 9px;
}
.host-avatar {
display: grid;
overflow: hidden;
width: 38px;
height: 38px;
place-items: center;
border-radius: 50%;
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
font-weight: 800;
}
.host-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.host-copy {
display: flex;
max-width: 150px;
flex-direction: column;
gap: 2px;
}
.host-copy strong {
overflow: hidden;
text-overflow: ellipsis;
}
.host-copy span {
color: var(--hy-theme-muted);
font-size: 11px;
}
.remove-button {
border: 0;
background: transparent;
color: var(--hy-theme-danger);
font-weight: 750;
}
.empty-state,
.stats-status {
padding: 24px;
color: var(--hy-theme-muted);
text-align: center;
}
.stats-status {
margin-bottom: 12px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-control);
background: var(--hy-theme-primary-soft);
}
.edit-dialog {
width: min(calc(100% - 32px), 420px);
padding: 0;
border: 0;
border-radius: var(--hy-theme-radius-modal);
background: var(--hy-theme-surface);
color: var(--hy-theme-text);
box-shadow: var(--hy-theme-modal-shadow);
}
.edit-dialog::backdrop {
background: rgba(31, 23, 45, 0.42);
}
.edit-dialog form {
display: grid;
gap: 16px;
padding: 24px;
}
.edit-dialog h2,
.edit-dialog p {
margin: 0;
}
.edit-dialog p {
color: var(--hy-theme-muted);
font-size: 13px;
line-height: 1.55;
}
.edit-dialog label {
display: grid;
gap: 8px;
color: var(--hy-theme-muted);
font-size: 13px;
}
.edit-dialog input {
width: 100%;
min-height: 46px;
padding: 0 14px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-control);
outline: none;
background: var(--hy-theme-surface-soft);
color: var(--hy-theme-text);
}
.edit-dialog input:focus {
border-color: var(--hy-theme-primary-strong);
box-shadow: 0 0 0 4px var(--hy-theme-focus-ring);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.dialog-actions > button:not(.primary-action, .danger-action) {
min-height: 42px;
padding: 0 16px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-pill);
background: var(--hy-theme-surface);
color: var(--hy-theme-text);
}
.is-loading .data-section,
.is-mutating button {
pointer-events: none;
opacity: 0.65;
}
@media (max-width: 520px) {
.guild-center__content {
padding-inline: 12px;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-grid article:nth-child(3n) {
border-inline-end: 1px solid var(--hy-theme-line);
}
.summary-grid article:nth-child(2n) {
border-inline-end: 0;
}
.summary-grid article:nth-last-child(-n + 2) {
border-bottom: 1px solid var(--hy-theme-line);
}
.summary-grid article:last-child {
border-bottom: 0;
}
}
@media (max-width: 380px) {
.agency-avatar {
flex-basis: 64px;
width: 64px;
height: 64px;
}
.balance-card {
align-items: flex-start;
flex-direction: column;
}
.balance-card .primary-action {
width: 100%;
}
}

View File

@ -0,0 +1,291 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>BD Center</title>
<link rel="stylesheet" href="../../../common/theme.css" />
<link rel="stylesheet" href="../../packages/ui/center.css" />
<link rel="stylesheet" href="./style.css" />
<script src="../../packages/core/product-context.js"></script>
<script src="../../products/fami/product.js"></script>
</head>
<body>
<div class="guild-center fami-bd-center">
<header class="center-header">
<button
class="icon-button"
id="backButton"
type="button"
data-i18n-aria="fami_common.back"
aria-label="Back"
>
<span aria-hidden="true"></span>
</button>
<h1 data-i18n="fami_bd.title">BD Center</h1>
<div class="language-switcher">
<button
class="language-button"
type="button"
data-language-toggle
data-current-lang
aria-expanded="false"
data-i18n-aria="fami_common.change_language"
aria-label="Change language"
>
EN
</button>
<div class="language-menu" data-language-menu hidden>
<button type="button" data-lang-option="en">EN</button
><button type="button" data-lang-option="ar">AR</button
><button type="button" data-lang-option="tr">TR</button
><button type="button" data-lang-option="es">ES</button>
</div>
</div>
</header>
<main class="guild-center__content" aria-live="polite">
<section class="guild-card profile-card">
<div class="profile-avatar">
<img id="profileAvatar" alt="" hidden /><span
id="profileInitial"
aria-hidden="true"
>B</span
>
</div>
<div class="profile-copy">
<span class="role-pill">BD</span
><strong id="profileName"></strong
><span id="profileUID">UID: —</span>
</div>
</section>
<section class="guild-card contact-card">
<span class="contact-icon" aria-hidden="true"></span>
<div>
<strong data-i18n="fami_bd.contact">WhatsApp</strong
><span id="contactValue"></span>
</div>
<button
id="editContactButton"
type="button"
data-i18n="fami_common.edit"
>
Edit
</button>
</section>
<button
class="add-agency-card"
id="addAgencyButton"
type="button"
>
<span aria-hidden="true"></span
><span data-i18n="fami_bd.add_agency">Add agency</span>
</button>
<section class="data-section" aria-labelledby="dataTitle">
<div class="section-title-row">
<h2 id="dataTitle" data-i18n="fami_bd.agency_data">
Agency data
</h2>
<span id="rangeCaption"></span>
</div>
<div
class="guild-filter-bar"
id="rangeFilters"
role="group"
data-i18n-aria="fami_range.label"
aria-label="Date range"
>
<button
type="button"
data-range="today"
data-i18n="fami_range.today"
>
Today</button
><button
type="button"
data-range="yesterday"
data-i18n="fami_range.yesterday"
>
Yesterday</button
><button
type="button"
data-range="last_7_days"
data-i18n="fami_range.last_7_days"
>
Last 7 days</button
><button
type="button"
data-range="last_30_days"
data-i18n="fami_range.last_30_days"
>
Last 30 days</button
><button
type="button"
data-range="this_month"
data-i18n="fami_range.this_month"
>
This month</button
><button
type="button"
data-range="last_month"
data-i18n="fami_range.last_month"
>
Last month</button
><button
type="button"
data-range="custom"
data-i18n="fami_range.custom"
>
Custom
</button>
</div>
<div class="stats-status" id="statsStatus" hidden></div>
<div class="guild-card agency-table-shell">
<table class="agency-table">
<thead>
<tr>
<th data-i18n="fami_bd.agency">Agency</th>
<th data-i18n="fami_bd.owner_id">
Owner ID
</th>
<th data-i18n="fami_bd.country">Country</th>
<th data-i18n="fami_agency.total_hosts">
Total hosts
</th>
<th data-i18n="fami_agency.gifted_hosts">
Gifted hosts
</th>
<th data-i18n="fami_bd.diamond_earnings">
Agency diamond earnings
</th>
<th data-i18n="fami_bd.joined_at">
Joined
</th>
</tr>
</thead>
<tbody id="agencyTableBody"></tbody>
</table>
<div
class="empty-state"
id="agencyEmpty"
hidden
data-i18n="fami_bd.no_agencies"
>
No agencies yet.
</div>
</div>
</section>
</main>
<dialog class="edit-dialog" id="contactDialog">
<form method="dialog" id="contactForm">
<h2 data-i18n="fami_bd.edit_contact">Edit WhatsApp</h2>
<label
><span data-i18n="fami_agency.contact_number"
>Contact number</span
><input
id="contactInput"
type="text"
maxlength="64"
autocomplete="tel"
/></label>
<div class="dialog-actions">
<button
type="button"
id="cancelContactButton"
data-i18n="fami_common.cancel"
>
Cancel</button
><button
class="primary-action"
type="submit"
data-i18n="fami_common.save"
>
Save
</button>
</div>
</form>
</dialog>
<dialog class="edit-dialog" id="rangeDialog">
<form method="dialog" id="rangeForm">
<h2 data-i18n="fami_range.custom_title">
Custom date range
</h2>
<label
><span data-i18n="fami_range.start">Start date</span
><input id="rangeStart" type="date" required /></label
><label
><span data-i18n="fami_range.end">End date</span
><input id="rangeEnd" type="date" required
/></label>
<div class="dialog-actions">
<button
type="button"
id="cancelRangeButton"
data-i18n="fami_common.cancel"
>
Cancel</button
><button
class="primary-action"
type="submit"
data-i18n="fami_common.apply"
>
Apply
</button>
</div>
</form>
</dialog>
<dialog class="edit-dialog" id="inviteDialog">
<form method="dialog" id="inviteForm">
<h2 data-i18n="fami_bd.add_agency">Add agency</h2>
<p data-i18n="fami_bd.invite_hint">
Enter the future agency owner's display ID. The agency
is created after the invitation is accepted.
</p>
<label
><span data-i18n="fami_bd.owner_id">Owner ID</span
><input
id="ownerDisplayID"
type="text"
inputmode="numeric"
maxlength="32"
required /></label
><label
><span data-i18n="fami_bd.agency_name">Agency name</span
><input id="agencyNameInput" type="text" maxlength="64"
/></label>
<div class="dialog-actions">
<button
type="button"
id="cancelInviteButton"
data-i18n="fami_common.cancel"
>
Cancel</button
><button
class="primary-action"
type="submit"
data-i18n="fami_agency.send_invite"
>
Send invite
</button>
</div>
</form>
</dialog>
</div>
<script src="../../../common/api.js"></script>
<script src="../../../common/params.js"></script>
<script src="../../../common/i18n.js"></script>
<script src="../../../common/jsbridge.js"></script>
<script src="../../../common/toast.js"></script>
<script src="../../packages/core/date-range.js"></script>
<script src="../../packages/bd-center/bd-center.js"></script>
<script src="./script.js"></script>
</body>
</html>

View File

@ -0,0 +1,292 @@
(function () {
'use strict';
var state = { range: null, data: null, loading: false, mutating: false };
function $(id) {
return document.getElementById(id);
}
function t(key, fallback) {
return window.HyAppI18n && window.HyAppI18n.t
? window.HyAppI18n.t(key, fallback)
: fallback || key;
}
function toast(message) {
if (window.HyAppToast) window.HyAppToast.show(message);
}
function count(value) {
return Number.isFinite(value)
? Number(value).toLocaleString(undefined, {
maximumFractionDigits: 0,
})
: '—';
}
function dateTime(value) {
return Number(value)
? new Date(Number(value)).toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: '—';
}
function renderProfile() {
if (!state.data) return;
var profile = state.data.profile || {};
$('profileName').textContent = profile.name || '—';
$('profileUID').textContent =
t('fami_common.uid_prefix', 'UID:') +
' ' +
(profile.displayUserId || profile.userId || '—');
$('profileInitial').textContent = String(profile.name || 'B')
.trim()
.charAt(0)
.toUpperCase();
$('profileAvatar').hidden = !profile.avatar;
$('profileInitial').hidden = !!profile.avatar;
if (profile.avatar) $('profileAvatar').src = profile.avatar;
$('contactValue').textContent = profile.contact || '—';
}
function agencyAvatar(item) {
var shell = document.createElement('span');
shell.className = 'agency-avatar';
if (item.avatar) {
var image = document.createElement('img');
image.src = item.avatar;
image.alt = '';
shell.appendChild(image);
} else {
shell.textContent = String(item.name || 'A')
.charAt(0)
.toUpperCase();
}
return shell;
}
function textCell(row, value) {
var cell = document.createElement('td');
cell.textContent = value;
row.appendChild(cell);
}
function renderAgencies() {
var agencies =
state.data && state.data.stats
? state.data.stats.agencies || []
: [];
var body = $('agencyTableBody');
body.textContent = '';
agencies.forEach(function (item) {
var row = document.createElement('tr');
var identityCell = document.createElement('td');
var identity = document.createElement('div');
identity.className = 'agency-identity';
identity.appendChild(agencyAvatar(item));
var copy = document.createElement('span');
copy.className = 'agency-copy';
var name = document.createElement('strong');
name.textContent = item.name || '—';
var id = document.createElement('span');
id.textContent =
t('fami_agency.id_prefix', 'Agency ID:') +
' ' +
(item.shortId || item.id || '—');
copy.appendChild(name);
copy.appendChild(id);
identity.appendChild(copy);
identityCell.appendChild(identity);
row.appendChild(identityCell);
textCell(row, item.ownerId || '—');
textCell(row, item.country || '—');
textCell(row, count(item.totalHosts));
textCell(row, count(item.giftedHostCount));
textCell(row, count(item.diamondEarnings));
textCell(row, dateTime(item.joinedAtMS));
body.appendChild(row);
});
$('agencyEmpty').hidden = agencies.length > 0;
}
function renderRange() {
document.querySelectorAll('[data-range]').forEach(function (button) {
button.setAttribute(
'aria-pressed',
button.getAttribute('data-range') === state.range.preset
? 'true'
: 'false'
);
button.disabled = state.loading;
});
$('rangeCaption').textContent =
state.range.startDate === state.range.endDate
? state.range.startDate
: state.range.startDate + ' ' + state.range.endDate;
}
function renderAll() {
renderProfile();
renderAgencies();
renderRange();
}
function setLoading(value) {
state.loading = value;
document.body.classList.toggle('is-loading', value);
renderRange();
}
function setMutating(value) {
state.mutating = value;
document.body.classList.toggle('is-mutating', value);
}
function load() {
setLoading(true);
return window.HyGuild.bdCenter
.load(state.range)
.then(function (data) {
state.data = data;
$('statsStatus').hidden = true;
renderAll();
})
.catch(function () {
$('statsStatus').hidden = false;
$('statsStatus').textContent = t(
'fami_bd.load_failed',
'Unable to load BD Center.'
);
toast($('statsStatus').textContent);
})
.finally(function () {
setLoading(false);
});
}
function selectRange(preset) {
if (preset === 'custom') {
$('rangeStart').value = state.range.startDate;
$('rangeEnd').value = state.range.endDate;
$('rangeDialog').showModal();
return;
}
state.range = window.HyGuild.dateRange.create(preset);
load();
}
function applyCustomRange(event) {
event.preventDefault();
try {
state.range = window.HyGuild.dateRange.create('custom', {
start: $('rangeStart').value,
end: $('rangeEnd').value,
});
} catch (_) {
toast(t('fami_range.invalid', 'Choose a valid date range.'));
return;
}
$('rangeDialog').close();
load();
}
function saveContact(event) {
event.preventDefault();
if (state.mutating) return;
var value = String($('contactInput').value || '').trim();
if (!value) {
toast(t('fami_agency.contact_required', 'Enter a contact number.'));
return;
}
setMutating(true);
window.HyGuild.bdCenter
.saveContact(value)
.then(function () {
state.data.profile.contact = value;
$('contactDialog').close();
renderProfile();
toast(t('fami_agency.contact_saved', 'Contact saved.'));
})
.catch(function () {
toast(
t(
'fami_agency.contact_save_failed',
'Unable to save contact.'
)
);
})
.finally(function () {
setMutating(false);
});
}
function inviteAgency(event) {
event.preventDefault();
if (state.mutating) return;
var ownerID = String($('ownerDisplayID').value || '').trim();
if (!ownerID) return;
setMutating(true);
window.HyGuild.bdCenter
.inviteAgency(ownerID, $('agencyNameInput').value)
.then(function () {
$('inviteDialog').close();
toast(
t(
'fami_bd.invite_sent',
'Invitation sent. The agency is created after acceptance.'
)
);
})
.catch(function () {
toast(t('fami_bd.invite_failed', 'Unable to invite agency.'));
})
.finally(function () {
setMutating(false);
});
}
function bind() {
$('backButton').addEventListener('click', function () {
if (window.history.length > 1) window.history.back();
else if (window.HyAppBridge) window.HyAppBridge.back();
});
$('editContactButton').addEventListener('click', function () {
$('contactInput').value =
(state.data && state.data.profile.contact) || '';
$('contactDialog').showModal();
});
$('cancelContactButton').addEventListener('click', function () {
$('contactDialog').close();
});
$('contactForm').addEventListener('submit', saveContact);
$('addAgencyButton').addEventListener('click', function () {
$('ownerDisplayID').value = '';
$('agencyNameInput').value = '';
$('inviteDialog').showModal();
});
$('cancelInviteButton').addEventListener('click', function () {
$('inviteDialog').close();
});
$('inviteForm').addEventListener('submit', inviteAgency);
$('rangeFilters').addEventListener('click', function (event) {
var button = event.target.closest('[data-range]');
if (button && !state.loading)
selectRange(button.getAttribute('data-range'));
});
$('cancelRangeButton').addEventListener('click', function () {
$('rangeDialog').close();
});
$('rangeForm').addEventListener('submit', applyCustomRange);
window.addEventListener('hyapp:i18n-ready', renderAll);
}
function init() {
state.range = window.HyGuild.dateRange.create('today');
bind();
renderRange();
load();
}
if (document.readyState === 'loading')
document.addEventListener('DOMContentLoaded', init);
else init();
})();

View File

@ -0,0 +1,376 @@
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
background: var(--hy-theme-bg);
color: var(--hy-theme-text);
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
.center-header {
position: sticky;
z-index: 20;
top: 0;
display: grid;
grid-template-columns: 48px 1fr 48px;
align-items: center;
min-height: calc(64px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 12px 0;
border-bottom: 1px solid var(--hy-theme-line);
background: color-mix(in srgb, var(--hy-theme-bg) 94%, transparent);
backdrop-filter: blur(16px);
}
.center-header h1 {
margin: 0;
font-size: 22px;
text-align: center;
}
.icon-button,
.language-button {
display: grid;
width: 44px;
height: 40px;
place-items: center;
border: 0;
border-radius: var(--hy-theme-radius-language);
background: var(--hy-theme-surface);
color: var(--hy-theme-text);
box-shadow: var(--hy-theme-shadow);
}
.icon-button span {
font-size: 38px;
font-weight: 300;
line-height: 30px;
}
.language-switcher {
position: relative;
}
.language-button {
font-size: 13px;
font-weight: 800;
}
.language-menu {
position: absolute;
top: 46px;
right: 0;
overflow: hidden;
width: 64px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-small);
background: var(--hy-theme-surface);
box-shadow: var(--hy-theme-modal-shadow);
}
[dir='rtl'] .language-menu {
right: auto;
left: 0;
}
.language-menu button {
width: 100%;
padding: 10px;
border: 0;
background: transparent;
color: var(--hy-theme-text);
}
.language-menu button.is-active {
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
}
.profile-card,
.contact-card {
display: flex;
align-items: center;
padding: 18px;
}
.profile-avatar {
display: grid;
flex: 0 0 76px;
overflow: hidden;
width: 76px;
height: 76px;
place-items: center;
border: 3px solid var(--hy-theme-primary);
border-radius: 50%;
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
font-size: 30px;
font-weight: 800;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
margin-inline-start: 16px;
}
.profile-copy strong {
overflow: hidden;
font-size: 21px;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-copy > span:not(.role-pill) {
color: var(--hy-theme-muted);
font-size: 13px;
}
.role-pill {
align-self: flex-start;
padding: 4px 11px;
border-radius: var(--hy-theme-radius-pill);
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
font-size: 12px;
font-weight: 800;
}
.contact-card {
gap: 14px;
}
.contact-icon {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border-radius: 50%;
background: color-mix(
in srgb,
var(--hy-theme-success) 13%,
var(--hy-theme-surface)
);
color: var(--hy-theme-success);
}
.contact-card > div {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 3px;
}
.contact-card > div span {
overflow: hidden;
color: var(--hy-theme-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-card > button {
border: 0;
background: transparent;
color: var(--hy-theme-primary-deep);
font-weight: 750;
}
.add-agency-card {
display: flex;
min-height: 82px;
align-items: center;
justify-content: center;
gap: 9px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-card);
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
box-shadow: var(--hy-theme-shadow);
font-weight: 800;
}
.data-section,
.agency-table-shell {
min-width: 0;
max-width: 100%;
}
.add-agency-card span:first-child {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 50%;
background: var(--hy-theme-surface);
font-size: 20px;
}
.section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-title-row h2 {
margin: 0;
font-size: 19px;
}
.section-title-row span {
color: var(--hy-theme-muted);
font-size: 13px;
}
.guild-filter-bar {
margin-bottom: 12px;
}
.guild-filter-bar button {
padding: 5px 4px;
font-size: 11px;
}
.agency-table-shell {
overflow-x: auto;
}
.agency-table {
width: 100%;
min-width: 900px;
border-collapse: collapse;
font-size: 12px;
}
.agency-table th,
.agency-table td {
padding: 13px 12px;
border-bottom: 1px solid var(--hy-theme-line);
text-align: start;
white-space: nowrap;
}
.agency-table th {
color: var(--hy-theme-muted);
font-size: 11px;
}
.agency-table tr:last-child td {
border-bottom: 0;
}
.agency-identity {
display: flex;
align-items: center;
gap: 9px;
}
.agency-avatar {
display: grid;
overflow: hidden;
width: 40px;
height: 40px;
place-items: center;
border-radius: 50%;
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
font-weight: 800;
}
.agency-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.agency-copy {
display: flex;
max-width: 170px;
flex-direction: column;
gap: 2px;
}
.agency-copy strong {
overflow: hidden;
text-overflow: ellipsis;
}
.agency-copy span {
color: var(--hy-theme-muted);
font-size: 11px;
}
.empty-state,
.stats-status {
padding: 24px;
color: var(--hy-theme-muted);
text-align: center;
}
.stats-status {
margin-bottom: 12px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-control);
background: var(--hy-theme-primary-soft);
}
.primary-action {
min-width: 94px;
min-height: 42px;
padding: 0 18px;
border: 0;
border-radius: var(--hy-theme-radius-pill);
background: var(--hy-theme-button);
color: #fff;
box-shadow: var(--hy-theme-button-shadow);
font-weight: 750;
}
.edit-dialog {
width: min(calc(100% - 32px), 420px);
padding: 0;
border: 0;
border-radius: var(--hy-theme-radius-modal);
background: var(--hy-theme-surface);
color: var(--hy-theme-text);
box-shadow: var(--hy-theme-modal-shadow);
}
.edit-dialog::backdrop {
background: rgba(31, 23, 45, 0.42);
}
.edit-dialog form {
display: grid;
gap: 16px;
padding: 24px;
}
.edit-dialog h2,
.edit-dialog p {
margin: 0;
}
.edit-dialog p {
color: var(--hy-theme-muted);
font-size: 13px;
line-height: 1.55;
}
.edit-dialog label {
display: grid;
gap: 8px;
color: var(--hy-theme-muted);
font-size: 13px;
}
.edit-dialog input {
width: 100%;
min-height: 46px;
padding: 0 14px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-control);
outline: none;
background: var(--hy-theme-surface-soft);
color: var(--hy-theme-text);
}
.edit-dialog input:focus {
border-color: var(--hy-theme-primary-strong);
box-shadow: 0 0 0 4px var(--hy-theme-focus-ring);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.dialog-actions > button:not(.primary-action) {
min-height: 42px;
padding: 0 16px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-pill);
background: var(--hy-theme-surface);
color: var(--hy-theme-text);
}
.is-loading .data-section,
.is-mutating button {
pointer-events: none;
opacity: 0.65;
}
@media (max-width: 520px) {
.guild-center__content {
padding-inline: 12px;
}
}

View File

@ -0,0 +1,297 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>Host Center</title>
<link rel="stylesheet" href="../../../common/theme.css" />
<link rel="stylesheet" href="../../packages/ui/center.css" />
<link rel="stylesheet" href="./style.css" />
<script src="../../packages/core/product-context.js"></script>
<script src="../../products/fami/product.js"></script>
</head>
<body>
<div class="guild-center fami-host-center">
<header class="center-header">
<button
class="icon-button"
id="backButton"
type="button"
aria-label="Back"
data-i18n-aria="fami_host.back"
>
<span aria-hidden="true"></span>
</button>
<h1 data-i18n="fami_host.title">Host Center</h1>
<div class="language-switcher">
<button
class="language-button"
type="button"
data-language-toggle
data-current-lang
aria-expanded="false"
aria-label="Change language"
data-i18n-aria="fami_host.change_language"
>
EN
</button>
<div class="language-menu" data-language-menu hidden>
<button type="button" data-lang-option="en">EN</button>
<button type="button" data-lang-option="ar">AR</button>
<button type="button" data-lang-option="tr">TR</button>
<button type="button" data-lang-option="es">ES</button>
</div>
</div>
</header>
<main class="guild-center__content" aria-live="polite">
<section class="guild-card profile-card">
<div class="avatar-shell">
<img id="profileAvatar" alt="" hidden />
<span id="profileInitial" aria-hidden="true">H</span>
</div>
<div class="profile-copy">
<strong id="profileName"></strong>
<span id="profileUID">UID: —</span>
<span class="agency-pill" id="agencyPill" hidden></span>
<span class="join-time" id="joinTime" hidden></span>
</div>
</section>
<section class="guild-card contact-card">
<span class="contact-icon" aria-hidden="true"></span>
<div>
<strong data-i18n="fami_host.contact">WhatsApp</strong>
<span id="contactValue"></span>
</div>
<button
id="editContactButton"
type="button"
data-i18n="fami_host.edit"
>
Edit
</button>
</section>
<section class="guild-card balance-card">
<div>
<span
class="card-label"
data-i18n="fami_host.diamond_balance"
>Diamond balance</span
>
<strong class="balance-value"
><span aria-hidden="true">💎</span
><span id="diamondBalance"></span></strong
>
</div>
<button
class="primary-action"
id="withdrawButton"
type="button"
data-i18n="fami_host.withdraw"
>
Withdraw
</button>
</section>
<section class="stats-section" aria-labelledby="statsTitle">
<div class="section-title-row">
<h2 id="statsTitle" data-i18n="fami_host.data_details">
Data details
</h2>
<span id="rangeCaption"></span>
</div>
<div
class="guild-filter-bar"
id="rangeFilters"
role="group"
aria-label="Date range"
>
<button
type="button"
data-range="today"
data-i18n="fami_range.today"
>
Today
</button>
<button
type="button"
data-range="yesterday"
data-i18n="fami_range.yesterday"
>
Yesterday
</button>
<button
type="button"
data-range="last_7_days"
data-i18n="fami_range.last_7_days"
>
Last 7 days
</button>
<button
type="button"
data-range="last_30_days"
data-i18n="fami_range.last_30_days"
>
Last 30 days
</button>
<button
type="button"
data-range="this_month"
data-i18n="fami_range.this_month"
>
This month
</button>
<button
type="button"
data-range="last_month"
data-i18n="fami_range.last_month"
>
Last month
</button>
<button
type="button"
data-range="custom"
data-i18n="fami_range.custom"
>
Custom
</button>
</div>
<div class="stats-status" id="statsStatus" hidden></div>
<div class="guild-card metrics-grid" id="metricsGrid">
<article data-metric="diamond_earnings">
<strong></strong
><span data-i18n="fami_metric.diamond_earnings"
>Diamond earnings</span
>
</article>
<article data-metric="diamond_exchanged">
<strong></strong
><span data-i18n="fami_metric.diamond_exchanged"
>Diamonds exchanged</span
>
</article>
<article data-metric="gift_senders">
<strong></strong
><span data-i18n="fami_metric.gift_senders"
>Gift senders</span
>
</article>
<article data-metric="online_duration_ms">
<strong></strong
><span data-i18n="fami_metric.online_duration"
>Online duration</span
>
</article>
<article data-metric="valid_mic_duration_ms">
<strong></strong
><span data-i18n="fami_metric.valid_mic_duration"
>Valid mic duration</span
>
</article>
<article data-metric="valid_mic_days">
<strong></strong
><span data-i18n="fami_metric.valid_mic_days"
>Valid mic days</span
>
</article>
<article data-metric="private_message_senders">
<strong></strong
><span
data-i18n="fami_metric.private_message_senders"
>Private message users</span
>
</article>
<article data-metric="new_followers">
<strong></strong
><span data-i18n="fami_metric.new_followers"
>New followers</span
>
</article>
</div>
</section>
</main>
<dialog class="edit-dialog" id="contactDialog">
<form method="dialog" id="contactForm">
<h2 data-i18n="fami_host.edit_contact">Edit WhatsApp</h2>
<label
><span data-i18n="fami_host.contact_number"
>Contact number</span
><input
id="contactInput"
type="text"
maxlength="64"
autocomplete="tel"
/></label>
<div class="dialog-actions">
<button
value="cancel"
type="button"
id="cancelContactButton"
data-i18n="fami_common.cancel"
>
Cancel
</button>
<button
class="primary-action"
value="save"
type="submit"
data-i18n="fami_common.save"
>
Save
</button>
</div>
</form>
</dialog>
<dialog class="edit-dialog" id="rangeDialog">
<form method="dialog" id="rangeForm">
<h2 data-i18n="fami_range.custom_title">
Custom date range
</h2>
<label
><span data-i18n="fami_range.start">Start date</span
><input id="rangeStart" type="date" required
/></label>
<label
><span data-i18n="fami_range.end">End date</span
><input id="rangeEnd" type="date" required
/></label>
<div class="dialog-actions">
<button
value="cancel"
type="button"
id="cancelRangeButton"
data-i18n="fami_common.cancel"
>
Cancel
</button>
<button
class="primary-action"
value="apply"
type="submit"
data-i18n="fami_common.apply"
>
Apply
</button>
</div>
</form>
</dialog>
</div>
<script src="../../../common/api.js"></script>
<script src="../../../common/params.js"></script>
<script src="../../../common/i18n.js"></script>
<script src="../../../common/jsbridge.js"></script>
<script src="../../../common/toast.js"></script>
<script src="../../packages/core/date-range.js"></script>
<script src="../../packages/host-center/host-center.js"></script>
<script src="./script.js"></script>
</body>
</html>

View File

@ -0,0 +1,279 @@
(function () {
'use strict';
var state = {
range: null,
data: null,
loading: false,
savingContact: false,
};
function $(id) {
return document.getElementById(id);
}
function t(key, fallback) {
return window.HyAppI18n && window.HyAppI18n.t
? window.HyAppI18n.t(key, fallback)
: fallback || key;
}
function toast(message) {
if (window.HyAppToast) window.HyAppToast.show(message);
}
function count(value) {
if (value === null || value === undefined || !Number.isFinite(value)) {
return '—';
}
return Number(value).toLocaleString(undefined, {
maximumFractionDigits: 0,
});
}
function duration(value) {
if (value === null || value === undefined || !Number.isFinite(value)) {
return '—';
}
var minutes = Math.max(0, Math.floor(Number(value) / 60000));
var days = Math.floor(minutes / 1440);
var hours = Math.floor((minutes % 1440) / 60);
var rest = minutes % 60;
var parts = [];
if (days) parts.push(days + t('fami_unit.day_short', 'd'));
if (hours) parts.push(hours + t('fami_unit.hour_short', 'h'));
if (!days && rest) parts.push(rest + t('fami_unit.minute_short', 'm'));
return parts.length
? parts.join(' ')
: '0' + t('fami_unit.minute_short', 'm');
}
function dateTime(value) {
if (!Number(value)) return '';
return new Date(Number(value)).toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function renderProfile() {
if (!state.data) return;
var profile = state.data.profile || {};
var agency = state.data.agency;
$('profileName').textContent = profile.name || '—';
$('profileUID').textContent =
t('fami_host.uid_prefix', 'UID:') +
' ' +
(profile.displayUserId || profile.userId || '—');
$('profileInitial').textContent = String(profile.name || 'H')
.trim()
.charAt(0)
.toUpperCase();
$('profileAvatar').hidden = !profile.avatar;
$('profileInitial').hidden = !!profile.avatar;
if (profile.avatar) $('profileAvatar').src = profile.avatar;
$('agencyPill').hidden = !agency;
$('joinTime').hidden = !agency || !agency.joinedAtMS;
if (agency) {
$('agencyPill').textContent =
t('fami_host.agency_id_prefix', 'Agency ID:') +
' ' +
(agency.shortId || agency.id || '—');
if (agency.joinedAtMS) {
$('joinTime').textContent =
t('fami_host.joined_at_prefix', 'Joined:') +
' ' +
dateTime(agency.joinedAtMS);
}
}
$('contactValue').textContent = profile.contact || '—';
$('diamondBalance').textContent = count(state.data.pointBalance);
}
function renderMetrics() {
var metrics =
state.data && state.data.stats ? state.data.stats.metrics : {};
window.HyGuild.hostCenter.metrics.forEach(function (definition) {
var node = document.querySelector(
'[data-metric="' + definition.key + '"] strong'
);
if (!node) return;
node.textContent =
definition.format === 'duration'
? duration(metrics[definition.key])
: count(metrics[definition.key]);
});
var statsError =
state.data && state.data.errors ? state.data.errors.stats : null;
$('statsStatus').hidden = !statsError;
if (statsError) {
$('statsStatus').textContent = t(
'fami_host.stats_unavailable',
'Statistics are temporarily unavailable.'
);
}
}
function renderRange() {
document.querySelectorAll('[data-range]').forEach(function (button) {
button.setAttribute(
'aria-pressed',
button.getAttribute('data-range') === state.range.preset
? 'true'
: 'false'
);
button.disabled = state.loading;
});
$('rangeCaption').textContent =
state.range.startDate === state.range.endDate
? state.range.startDate
: state.range.startDate + ' ' + state.range.endDate;
}
function setLoading(loading) {
state.loading = loading;
document.body.classList.toggle('is-loading', loading);
renderRange();
}
function load() {
setLoading(true);
return window.HyGuild.hostCenter
.load(state.range)
.then(function (data) {
state.data = data;
renderProfile();
renderMetrics();
})
.catch(function () {
toast(
t('fami_host.load_failed', 'Unable to load Host Center.')
);
$('statsStatus').hidden = false;
$('statsStatus').textContent = t(
'fami_host.load_failed',
'Unable to load Host Center.'
);
})
.finally(function () {
setLoading(false);
});
}
function selectRange(preset) {
if (preset === 'custom') {
$('rangeStart').value = state.range.startDate;
$('rangeEnd').value = state.range.endDate;
$('rangeDialog').showModal();
return;
}
state.range = window.HyGuild.dateRange.create(preset);
renderRange();
load();
}
function saveContact(event) {
event.preventDefault();
if (state.savingContact) return;
var value = String($('contactInput').value || '').trim();
if (!value) {
toast(t('fami_host.contact_required', 'Enter a contact number.'));
return;
}
state.savingContact = true;
window.HyGuild.hostCenter
.saveContact(value)
.then(function () {
state.data.profile.contact = value;
$('contactValue').textContent = value;
$('contactDialog').close();
toast(t('fami_host.contact_saved', 'Contact saved.'));
})
.catch(function () {
toast(
t(
'fami_host.contact_save_failed',
'Unable to save contact.'
)
);
})
.finally(function () {
state.savingContact = false;
});
}
function applyCustomRange(event) {
event.preventDefault();
try {
state.range = window.HyGuild.dateRange.create('custom', {
start: $('rangeStart').value,
end: $('rangeEnd').value,
});
} catch (_) {
toast(t('fami_range.invalid', 'Choose a valid date range.'));
return;
}
$('rangeDialog').close();
renderRange();
load();
}
function bind() {
$('backButton').addEventListener('click', function () {
if (window.history.length > 1) window.history.back();
else if (window.HyAppBridge) window.HyAppBridge.back();
});
$('withdrawButton').addEventListener('click', function () {
// 钱包是独立页面包;统一 URL builder 继承 token/env/lang 并强制 app_code=fami。
window.location.href =
window.HyGuild.product.buildURL('../wallet/');
});
$('editContactButton').addEventListener('click', function () {
$('contactInput').value =
(state.data && state.data.profile.contact) || '';
$('contactDialog').showModal();
});
$('cancelContactButton').addEventListener('click', function () {
$('contactDialog').close();
});
$('contactForm').addEventListener('submit', saveContact);
$('rangeFilters').addEventListener('click', function (event) {
var button = event.target.closest('[data-range]');
if (button && !state.loading) {
selectRange(button.getAttribute('data-range'));
}
});
$('cancelRangeButton').addEventListener('click', function () {
$('rangeDialog').close();
});
$('rangeForm').addEventListener('submit', applyCustomRange);
window.addEventListener('hyapp:i18n-ready', function () {
renderProfile();
renderMetrics();
});
}
function init() {
state.range = window.HyGuild.dateRange.create('today');
bind();
renderRange();
load().finally(function () {
if (window.HyAppBridge) {
window.HyAppBridge.ready({
page: 'fami-host-center',
app_code: 'fami',
});
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@ -0,0 +1,409 @@
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
background: var(--hy-theme-bg);
color: var(--hy-theme-text);
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
.fami-host-center {
background:
radial-gradient(
circle at 16% 5%,
var(--hy-theme-primary-soft),
transparent 32%
),
var(--hy-theme-bg);
}
.center-header {
position: sticky;
z-index: 20;
top: 0;
display: grid;
grid-template-columns: 48px 1fr 48px;
align-items: center;
min-height: calc(64px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 12px 0;
border-bottom: 1px solid var(--hy-theme-line);
background: color-mix(in srgb, var(--hy-theme-bg) 88%, transparent);
backdrop-filter: blur(18px);
}
.center-header h1 {
margin: 0;
font-size: 22px;
line-height: 1.2;
text-align: center;
}
.icon-button,
.language-button {
display: grid;
place-items: center;
width: 44px;
height: 40px;
border: 0;
border-radius: var(--hy-theme-radius-language);
background: var(--hy-theme-surface);
color: var(--hy-theme-text);
box-shadow: var(--hy-theme-shadow);
}
.icon-button span {
font-size: 38px;
font-weight: 300;
line-height: 30px;
}
.language-switcher {
position: relative;
}
.language-button {
font-size: 13px;
font-weight: 800;
}
.language-menu {
position: absolute;
top: 46px;
right: 0;
overflow: hidden;
width: 64px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-small);
background: var(--hy-theme-surface);
box-shadow: var(--hy-theme-modal-shadow);
}
.language-menu button {
width: 100%;
padding: 10px;
border: 0;
background: transparent;
color: var(--hy-theme-text);
}
.language-menu button.is-active {
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
}
.profile-card,
.contact-card,
.balance-card {
display: flex;
align-items: center;
padding: 18px;
}
.avatar-shell {
display: grid;
flex: 0 0 76px;
place-items: center;
overflow: hidden;
width: 76px;
height: 76px;
border: 3px solid var(--hy-theme-primary);
border-radius: 50%;
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
font-size: 30px;
font-weight: 800;
}
.avatar-shell img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
margin-inline-start: 16px;
}
.profile-copy strong {
overflow: hidden;
font-size: 21px;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-copy > span:not(.agency-pill) {
color: var(--hy-theme-muted);
font-size: 13px;
}
.agency-pill {
align-self: flex-start;
padding: 5px 10px;
border-radius: var(--hy-theme-radius-pill);
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
font-size: 12px;
font-weight: 750;
}
.contact-card {
gap: 14px;
}
.contact-icon {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border-radius: 50%;
background: color-mix(
in srgb,
var(--hy-theme-success) 14%,
var(--hy-theme-surface)
);
color: var(--hy-theme-success);
font-size: 20px;
}
.contact-card > div {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 3px;
}
.contact-card > div span {
overflow: hidden;
color: var(--hy-theme-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-card > button {
border: 0;
background: transparent;
color: var(--hy-theme-primary-deep);
font-weight: 750;
}
.balance-card {
justify-content: space-between;
gap: 18px;
}
.balance-card > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
}
.card-label,
.section-title-row span {
color: var(--hy-theme-muted);
font-size: 13px;
}
.balance-value {
display: flex;
align-items: center;
gap: 8px;
overflow-wrap: anywhere;
font-size: clamp(20px, 6vw, 30px);
}
.primary-action {
min-width: 94px;
min-height: 42px;
padding: 0 18px;
border: 0;
border-radius: var(--hy-theme-radius-pill);
background: var(--hy-theme-button);
color: white;
box-shadow: var(--hy-theme-button-shadow);
font-weight: 750;
}
.section-title-row {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-title-row h2 {
margin: 0;
font-size: 19px;
}
.guild-filter-bar {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 12px;
}
.guild-filter-bar button {
padding: 5px 4px;
font-size: 11px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: hidden;
}
.metrics-grid article {
display: flex;
min-height: 104px;
align-items: center;
justify-content: center;
padding: 14px 8px;
border-right: 1px solid var(--hy-theme-line);
border-bottom: 1px solid var(--hy-theme-line);
flex-direction: column;
gap: 8px;
text-align: center;
}
.metrics-grid article:nth-child(3n) {
border-right: 0;
}
.metrics-grid article:nth-last-child(-n + 2) {
border-bottom: 0;
}
.metrics-grid article strong {
color: var(--hy-theme-text);
font-size: 19px;
}
.metrics-grid article span {
color: var(--hy-theme-muted);
font-size: 12px;
line-height: 1.35;
}
.stats-status {
margin-bottom: 10px;
padding: 11px 14px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-small);
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
font-size: 13px;
}
.edit-dialog {
width: min(420px, calc(100vw - 32px));
padding: 0;
border: 0;
border-radius: var(--hy-theme-radius-modal);
background: var(--hy-theme-surface);
color: var(--hy-theme-text);
box-shadow: var(--hy-theme-modal-shadow);
}
.edit-dialog::backdrop {
background: rgb(25 18 38 / 45%);
}
.edit-dialog form {
display: grid;
gap: 18px;
padding: 22px;
}
.edit-dialog h2 {
margin: 0;
font-size: 20px;
}
.edit-dialog label {
display: grid;
gap: 8px;
color: var(--hy-theme-muted);
font-size: 13px;
}
.edit-dialog input {
width: 100%;
min-height: 46px;
padding: 0 13px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-control);
outline: none;
background: var(--hy-theme-surface-soft);
color: var(--hy-theme-text);
}
.edit-dialog input:focus {
border-color: var(--hy-theme-primary-strong);
box-shadow: 0 0 0 4px var(--hy-theme-focus-ring);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.dialog-actions button:not(.primary-action) {
min-height: 42px;
padding: 0 18px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-pill);
background: var(--hy-theme-surface);
color: var(--hy-theme-text);
}
[dir='rtl'] .language-menu {
right: auto;
left: 0;
}
@media (max-width: 420px) {
.guild-filter-bar {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metrics-grid article:nth-child(3n) {
border-right: 1px solid var(--hy-theme-line);
}
.metrics-grid article:nth-child(2n) {
border-right: 0;
}
.metrics-grid article:nth-last-child(-n + 2) {
border-bottom: 0;
}
}

View File

@ -0,0 +1,207 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>Wallet</title>
<link rel="stylesheet" href="../../../common/theme.css" />
<link rel="stylesheet" href="../../packages/ui/center.css" />
<link rel="stylesheet" href="./style.css" />
<script src="../../packages/core/product-context.js"></script>
<script src="../../products/fami/product.js"></script>
</head>
<body>
<div class="guild-center fami-wallet">
<header class="center-header">
<button
class="icon-button"
id="backButton"
type="button"
aria-label="Back"
data-i18n-aria="fami_common.back"
>
<span aria-hidden="true"></span>
</button>
<h1 data-i18n="fami_wallet.title">Withdraw</h1>
<div class="language-switcher">
<button
class="language-button"
type="button"
data-language-toggle
data-current-lang
aria-expanded="false"
aria-label="Change language"
data-i18n-aria="fami_common.change_language"
>
EN
</button>
<div class="language-menu" data-language-menu hidden>
<button type="button" data-lang-option="en">EN</button>
<button type="button" data-lang-option="ar">AR</button>
<button type="button" data-lang-option="tr">TR</button>
<button type="button" data-lang-option="es">ES</button>
</div>
</div>
</header>
<main class="guild-center__content wallet-content">
<div class="wallet-tabs" role="tablist">
<button
id="sellerTab"
type="button"
role="tab"
aria-selected="true"
data-mode="seller"
data-i18n="fami_wallet.seller_tab"
>
Coin seller
</button>
<button
id="usdtTab"
type="button"
role="tab"
aria-selected="false"
data-mode="usdt"
data-i18n="fami_wallet.usdt_tab"
>
USDT
</button>
</div>
<section class="guild-card wallet-balance-card">
<span data-i18n="fami_wallet.point_balance"
>POINT balance</span
>
<strong id="pointBalance"></strong>
<small id="balanceUSD"></small>
</section>
<section class="guild-card amount-card">
<label for="pointAmount" data-i18n="fami_wallet.amount"
>Withdrawal amount</label
>
<div class="amount-input-row">
<span aria-hidden="true"></span>
<input
id="pointAmount"
type="number"
inputmode="numeric"
min="1"
step="1"
data-i18n-placeholder="fami_wallet.amount_placeholder"
placeholder="Enter POINT amount"
/>
<button
id="allButton"
type="button"
data-i18n="fami_wallet.all"
>
All
</button>
</div>
<div class="conversion-row">
<span data-i18n="fami_wallet.estimated_receive"
>Estimated receive</span
>
<strong id="estimatedReceive"></strong>
</div>
<small id="policyCaption"></small>
</section>
<section id="sellerPanel" role="tabpanel">
<div class="section-heading">
<h2 data-i18n="fami_wallet.choose_seller">
Choose coin seller
</h2>
</div>
<div class="seller-list" id="sellerList"></div>
<div class="empty-state" id="sellerEmpty" hidden>
<strong data-i18n="fami_wallet.no_sellers"
>No eligible coin sellers</strong
>
<span data-i18n="fami_wallet.no_sellers_hint"
>Availability depends on your country and the Admin
whitelist.</span
>
</div>
<button
class="wallet-submit"
id="sellerSubmit"
type="button"
data-i18n="fami_wallet.withdraw_now"
>
Withdraw now
</button>
</section>
<section id="usdtPanel" role="tabpanel" hidden>
<div class="section-heading">
<h2 data-i18n="fami_wallet.usdt_address">
USDT TRC20 address
</h2>
<p data-i18n="fami_wallet.usdt_warning">
Withdrawals are sent only to your saved TRC20
address.
</p>
</div>
<div class="address-status" id="addressStatus"></div>
<label class="address-label">
<span data-i18n="fami_wallet.trc20_address"
>TRC20 address</span
>
<input
id="addressInput"
type="text"
maxlength="64"
autocomplete="off"
spellcheck="false"
data-i18n-placeholder="fami_wallet.address_placeholder"
placeholder="T..."
/>
</label>
<button
class="secondary-submit"
id="saveAddressButton"
type="button"
data-i18n="fami_wallet.save_address"
>
Save address
</button>
<button
class="wallet-submit"
id="usdtSubmit"
type="button"
data-i18n="fami_wallet.withdraw_now"
>
Withdraw now
</button>
</section>
<section class="history-section">
<div class="section-heading">
<h2 data-i18n="fami_wallet.history">
Withdrawal history
</h2>
</div>
<div class="history-list" id="historyList"></div>
<div class="empty-state" id="historyEmpty" hidden>
<span data-i18n="fami_wallet.no_history"
>No withdrawal records</span
>
</div>
</section>
</main>
</div>
<script src="../../../common/api.js"></script>
<script src="../../../common/params.js"></script>
<script src="../../../common/i18n.js"></script>
<script src="../../../common/jsbridge.js"></script>
<script src="../../../common/toast.js"></script>
<script src="../../packages/wallet/wallet.js"></script>
<script src="./script.js"></script>
</body>
</html>

417
guild/fami/wallet/script.js Normal file
View File

@ -0,0 +1,417 @@
(function () {
'use strict';
var state = {
data: null,
mode: 'seller',
selectedSellerID: '',
busy: false,
};
function $(id) {
return document.getElementById(id);
}
function t(key, fallback) {
return window.HyAppI18n && window.HyAppI18n.t
? window.HyAppI18n.t(key, fallback)
: fallback || key;
}
function toast(message) {
if (window.HyAppToast) window.HyAppToast.show(message);
}
function number(value) {
var normalized = value || 0;
if (
typeof BigInt === 'function' &&
typeof normalized === 'string' &&
/^-?\d+$/.test(normalized)
) {
return new Intl.NumberFormat().format(BigInt(normalized));
}
return Number(normalized).toLocaleString(undefined, {
maximumFractionDigits: 0,
});
}
function amount() {
var value = Number($('pointAmount').value || 0);
return Number.isSafeInteger(value) && value > 0 ? value : 0;
}
function selectedSeller() {
if (!state.data) return null;
return state.data.sellers.find(function (seller) {
return seller.userId === state.selectedSellerID;
});
}
function setBusy(value) {
state.busy = value;
document.body.classList.toggle('is-busy', value);
$('sellerSubmit').disabled = value || !selectedSeller();
$('usdtSubmit').disabled = value;
$('saveAddressButton').disabled = value;
}
function renderOverview() {
if (!state.data) return;
$('pointBalance').textContent = number(state.data.balance.available);
$('balanceUSD').textContent =
'≈ ' + state.data.balance.displayUSD + ' USDT';
$('addressInput').value = state.data.address || '';
$('addressStatus').classList.toggle(
'has-address',
Boolean(state.data.address)
);
$('addressStatus').textContent = state.data.address
? t('fami_wallet.saved_address', 'Saved address:') +
' ' +
state.data.address
: t('fami_wallet.no_address', 'No USDT address saved.');
$('policyCaption').textContent =
t('fami_wallet.policy_caption', 'Rate and fee are set by Admin.') +
' · 1 USDT = ' +
number(state.data.pointsPerUSD) +
' POINT · ' +
(state.data.feeBPS / 100).toFixed(2) +
'%';
if (!$('pointAmount').value && state.data.minimumPoints > 0) {
$('pointAmount').value = String(state.data.minimumPoints);
}
renderConversion();
}
function renderSellers() {
var sellers = state.data ? state.data.sellers : [];
if (
sellers.length &&
!sellers.some(function (seller) {
return seller.userId === state.selectedSellerID;
})
) {
state.selectedSellerID = sellers[0].userId;
}
$('sellerList').innerHTML = sellers
.map(function (seller) {
var selected = seller.userId === state.selectedSellerID;
var avatar = seller.avatar
? '<img src="' + escapeHTML(seller.avatar) + '" alt="" />'
: escapeHTML(
(seller.nickname || 'C').charAt(0).toUpperCase()
);
return (
'<button class="seller-card" type="button" data-seller-id="' +
escapeHTML(seller.userId) +
'" aria-pressed="' +
selected +
'"><span class="seller-avatar">' +
avatar +
'</span><span class="seller-copy"><strong>' +
escapeHTML(seller.nickname || '—') +
'</strong><span>' +
t('fami_common.uid_prefix', 'UID:') +
' ' +
escapeHTML(seller.displayUserId || seller.userId) +
'</span><span class="seller-ratio">' +
number(seller.pointAmount) +
' : ' +
number(seller.sellerCoinAmount) +
'</span></span><span class="seller-check" aria-hidden="true">' +
(selected ? '✓' : '○') +
'</span></button>'
);
})
.join('');
$('sellerEmpty').hidden = sellers.length > 0;
setBusy(state.busy);
}
function renderHistory() {
var items = state.data ? state.data.history || [] : [];
$('historyList').innerHTML = items
.map(function (item) {
var seller = item.counterpartyUserId
? ' · UID ' + escapeHTML(item.counterpartyUserId)
: '';
return (
'<article class="history-item"><strong>' +
escapeHTML(historyLabel(item.bizType)) +
seller +
'</strong><span class="history-amount">' +
(item.availableDelta > 0 ? '+' : '') +
number(item.availableDelta) +
'</span><small>' +
dateTime(item.createdAtMS) +
'</small></article>'
);
})
.join('');
$('historyEmpty').textContent = t(
'fami_wallet.no_history',
'No withdrawal records'
);
$('historyEmpty').hidden = items.length > 0;
if (state.data && state.data.historyUnavailable) {
$('historyEmpty').hidden = false;
$('historyEmpty').textContent = t(
'fami_wallet.history_unavailable',
'History is temporarily unavailable.'
);
}
}
function historyLabel(bizType) {
if (bizType === 'point_transfer_to_coin_seller') {
return t('fami_wallet.seller_withdrawal', 'Coin seller withdrawal');
}
if (String(bizType).indexOf('withdrawal') >= 0) {
return t('fami_wallet.usdt_withdrawal', 'USDT withdrawal');
}
return t('fami_wallet.point_transaction', 'POINT transaction');
}
function renderConversion() {
var points = amount();
if (!state.data || !points) {
$('estimatedReceive').textContent = '—';
return;
}
if (state.mode === 'seller') {
var seller = selectedSeller();
var receive = seller ? ratioAmount(points, seller) : '0';
$('estimatedReceive').textContent =
number(receive) +
' ' +
t('fami_wallet.seller_coin', 'seller coins');
return;
}
var fee = Math.floor((points * state.data.feeBPS) / 10000);
var net = points - fee;
$('estimatedReceive').textContent =
(net / state.data.pointsPerUSD).toFixed(2) + ' USDT';
}
function ratioAmount(points, seller) {
// 余额可达到万亿级Number 中间乘法会超过安全整数BigInt 只用于前端预计值,最终到账仍由 wallet-service 计算。
if (typeof BigInt === 'function') {
return (
(BigInt(points) * BigInt(seller.sellerCoinAmount)) /
BigInt(seller.pointAmount)
).toString();
}
return String(
Math.floor((points * seller.sellerCoinAmount) / seller.pointAmount)
);
}
function selectMode(mode) {
state.mode = mode === 'usdt' ? 'usdt' : 'seller';
$('sellerPanel').hidden = state.mode !== 'seller';
$('usdtPanel').hidden = state.mode !== 'usdt';
document.querySelectorAll('[data-mode]').forEach(function (button) {
button.setAttribute(
'aria-selected',
button.getAttribute('data-mode') === state.mode
? 'true'
: 'false'
);
});
renderConversion();
}
function load() {
setBusy(true);
return window.HyGuild.wallet
.load()
.then(function (data) {
state.data = data;
renderOverview();
renderSellers();
renderHistory();
})
.catch(function () {
toast(t('fami_wallet.load_failed', 'Unable to load wallet.'));
})
.finally(function () {
setBusy(false);
});
}
function submitSeller() {
var seller = selectedSeller();
var points = amount();
if (!seller || !validateAmount(points, state.data.minimumPoints))
return;
setBusy(true);
window.HyGuild.wallet
.transferToCoinSeller(seller.userId, points)
.then(function () {
toast(t('fami_wallet.submitted', 'Withdrawal submitted.'));
return load();
})
.catch(function () {
toast(
t(
'fami_wallet.submit_failed',
'Unable to submit withdrawal.'
)
);
})
.finally(function () {
setBusy(false);
});
}
function submitUSDT() {
var points = amount();
if (!validateAmount(points, state.data.minimumPoints)) return;
var address = String($('addressInput').value || '').trim();
if (!validTRC20(address)) {
toast(
t('fami_wallet.invalid_address', 'Enter a valid TRC20 address.')
);
return;
}
setBusy(true);
window.HyGuild.wallet
.withdrawUSDT(points, address)
.then(function () {
toast(t('fami_wallet.submitted', 'Withdrawal submitted.'));
return load();
})
.catch(function () {
toast(
t(
'fami_wallet.submit_failed',
'Unable to submit withdrawal.'
)
);
})
.finally(function () {
setBusy(false);
});
}
function saveAddress() {
var address = String($('addressInput').value || '').trim();
if (!validTRC20(address)) {
toast(
t('fami_wallet.invalid_address', 'Enter a valid TRC20 address.')
);
return;
}
setBusy(true);
window.HyGuild.wallet
.saveAddress(address)
.then(function () {
state.data.address = address;
renderOverview();
toast(t('fami_wallet.address_saved', 'Address saved.'));
})
.catch(function () {
toast(
t(
'fami_wallet.address_save_failed',
'Unable to save address.'
)
);
})
.finally(function () {
setBusy(false);
});
}
function validateAmount(points, minimum) {
if (!points || !state.data || points > state.data.balance.available) {
toast(
t(
'fami_wallet.invalid_amount',
'Enter an available POINT amount.'
)
);
return false;
}
if (points < minimum) {
toast(
t('fami_wallet.minimum_amount', 'Minimum POINT amount:') +
' ' +
number(minimum)
);
return false;
}
return true;
}
function validTRC20(value) {
return /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(value);
}
function escapeHTML(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function dateTime(value) {
if (!Number(value)) return '—';
return new Date(Number(value)).toLocaleString();
}
function bind() {
$('backButton').addEventListener('click', function () {
if (window.history.length > 1) window.history.back();
else if (window.HyAppBridge) window.HyAppBridge.back();
});
document
.querySelector('.wallet-tabs')
.addEventListener('click', function (event) {
var button = event.target.closest('[data-mode]');
if (button) selectMode(button.getAttribute('data-mode'));
});
$('sellerList').addEventListener('click', function (event) {
var button = event.target.closest('[data-seller-id]');
if (!button || state.busy) return;
state.selectedSellerID = button.getAttribute('data-seller-id');
renderSellers();
renderConversion();
});
$('pointAmount').addEventListener('input', renderConversion);
$('allButton').addEventListener('click', function () {
if (!state.data) return;
$('pointAmount').value = String(state.data.balance.available);
renderConversion();
});
$('sellerSubmit').addEventListener('click', submitSeller);
$('usdtSubmit').addEventListener('click', submitUSDT);
$('saveAddressButton').addEventListener('click', saveAddress);
window.addEventListener('hyapp:i18n-ready', function () {
renderOverview();
renderHistory();
});
}
function init() {
bind();
selectMode('seller');
load().finally(function () {
if (window.HyAppBridge) {
window.HyAppBridge.ready({
page: 'fami-wallet',
app_code: 'fami',
});
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

396
guild/fami/wallet/style.css Normal file
View File

@ -0,0 +1,396 @@
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
background: var(--hy-theme-bg);
color: var(--hy-theme-text);
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
.center-header {
position: sticky;
z-index: 20;
top: 0;
display: grid;
grid-template-columns: 48px 1fr 48px;
align-items: center;
min-height: calc(64px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 12px 0;
border-bottom: 1px solid var(--hy-theme-line);
background: var(--hy-theme-bg);
}
.center-header h1 {
margin: 0;
font-size: 22px;
text-align: center;
}
.icon-button,
.language-button {
display: grid;
width: 44px;
height: 40px;
place-items: center;
border: 0;
border-radius: var(--hy-theme-radius-language);
background: var(--hy-theme-surface);
color: var(--hy-theme-text);
box-shadow: var(--hy-theme-shadow);
}
.icon-button span {
font-size: 38px;
font-weight: 300;
line-height: 30px;
}
.language-switcher {
position: relative;
}
.language-button {
font-size: 13px;
font-weight: 800;
}
.language-menu {
position: absolute;
top: 46px;
right: 0;
overflow: hidden;
width: 64px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-small);
background: var(--hy-theme-surface);
box-shadow: var(--hy-theme-modal-shadow);
}
[dir='rtl'] .language-menu {
right: auto;
left: 0;
}
.language-menu button {
width: 100%;
padding: 10px;
border: 0;
background: transparent;
color: var(--hy-theme-text);
}
.wallet-content {
gap: 12px;
}
.wallet-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 4px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-control);
background: var(--hy-theme-surface);
}
.wallet-tabs button {
min-height: 40px;
border: 0;
border-radius: calc(var(--hy-theme-radius-control) - 4px);
background: transparent;
color: var(--hy-theme-muted);
font-weight: 700;
}
.wallet-tabs button[aria-selected='true'] {
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
}
.wallet-balance-card,
.amount-card {
display: grid;
gap: 8px;
padding: 18px;
}
.wallet-balance-card > span,
.amount-card > label,
.amount-card > small {
color: var(--hy-theme-muted);
font-size: 13px;
}
.wallet-balance-card strong {
overflow-wrap: anywhere;
color: var(--hy-theme-text);
font-size: clamp(27px, 8vw, 38px);
line-height: 1.1;
}
.wallet-balance-card small {
color: var(--hy-theme-primary-deep);
}
.amount-input-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 48px;
padding: 0 12px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-control);
background: var(--hy-theme-surface-soft);
}
.amount-input-row > span {
color: var(--hy-theme-primary-deep);
}
.amount-input-row input,
.address-label input {
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--hy-theme-text);
font: inherit;
}
.amount-input-row input {
width: 100%;
font-size: 18px;
font-weight: 750;
}
.amount-input-row button {
border: 0;
background: transparent;
color: var(--hy-theme-primary-deep);
font-weight: 750;
}
.conversion-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--hy-theme-muted);
font-size: 13px;
}
.conversion-row strong {
color: var(--hy-theme-text);
font-size: 15px;
}
#sellerPanel,
#usdtPanel,
.history-section {
display: grid;
gap: 12px;
}
#sellerPanel[hidden],
#usdtPanel[hidden] {
display: none;
}
.section-heading h2 {
margin: 2px 0;
color: var(--hy-theme-text);
font-size: 16px;
}
.section-heading p {
margin: 4px 0 0;
color: var(--hy-theme-muted);
font-size: 12px;
line-height: 1.5;
}
.seller-list,
.history-list {
display: grid;
gap: 10px;
}
.seller-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-card);
background: var(--hy-theme-surface);
color: var(--hy-theme-text);
text-align: start;
box-shadow: var(--hy-theme-shadow);
}
.seller-card[aria-pressed='true'] {
border-color: var(--hy-theme-primary-strong);
box-shadow: 0 0 0 2px var(--hy-theme-focus-ring);
}
.seller-avatar {
display: grid;
width: 44px;
height: 44px;
place-items: center;
overflow: hidden;
border-radius: var(--hy-theme-radius-circle);
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
font-weight: 800;
}
.seller-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.seller-copy {
display: grid;
min-width: 0;
gap: 3px;
}
.seller-copy strong,
.seller-copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.seller-copy span,
.seller-ratio {
color: var(--hy-theme-muted);
font-size: 12px;
}
.seller-check {
color: var(--hy-theme-primary-deep);
font-size: 20px;
}
.wallet-submit,
.secondary-submit {
min-height: 48px;
border-radius: var(--hy-theme-radius-control);
font-weight: 800;
}
.wallet-submit {
border: 0;
background: var(--hy-theme-button);
color: white;
box-shadow: var(--hy-theme-button-shadow);
}
.secondary-submit {
border: 1px solid var(--hy-theme-primary-strong);
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
}
.wallet-submit:disabled,
.secondary-submit:disabled {
background: var(--hy-theme-button-disabled);
color: var(--hy-theme-button-disabled-text);
box-shadow: none;
}
.address-status,
.address-label {
padding: 14px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-control);
background: var(--hy-theme-surface);
}
.address-status {
color: var(--hy-theme-muted);
font-size: 13px;
overflow-wrap: anywhere;
}
.address-status.has-address {
color: var(--hy-theme-primary-deep);
}
.address-label {
display: grid;
gap: 8px;
color: var(--hy-theme-muted);
font-size: 12px;
}
.address-label input {
padding: 6px 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.history-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px 12px;
padding: 14px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-control);
background: var(--hy-theme-surface);
}
.history-item strong {
color: var(--hy-theme-text);
}
.history-item .history-amount {
color: var(--hy-theme-danger);
font-weight: 800;
}
.history-item small {
grid-column: 1 / -1;
color: var(--hy-theme-muted);
}
.empty-state {
display: grid;
gap: 5px;
padding: 20px;
border: 1px dashed var(--hy-theme-line);
border-radius: var(--hy-theme-radius-card);
color: var(--hy-theme-muted);
text-align: center;
}
.empty-state[hidden] {
display: none;
}
body.is-busy button,
body.is-busy input {
pointer-events: none;
}

View File

@ -0,0 +1,243 @@
(function () {
'use strict';
function params() {
return new URLSearchParams(window.location.search || '');
}
function settled(promise) {
return Promise.resolve(promise).then(
function (value) {
return { ok: true, value: value };
},
function (error) {
return { ok: false, error: error };
}
);
}
function firstBalance(payload, assetType) {
var items = Array.isArray(payload)
? payload
: (payload && (payload.balances || payload.items)) || [];
return (
items.find(function (item) {
return (
String(
item.asset_type || item.assetType || ''
).toUpperCase() === assetType
);
}) || null
);
}
function normalizeProfile(payload) {
var profile =
(payload && (payload.profile || payload.user)) || payload || {};
return {
contact: String(profile.contact_info || profile.contactInfo || ''),
};
}
function normalizeOverview(payload) {
var agency = (payload && payload.agency) || {};
return {
agency: {
id: String(agency.agency_id || agency.agencyId || ''),
shortId: String(
agency.short_id ||
agency.shortId ||
agency.agency_id ||
agency.agencyId ||
''
),
name: String(agency.name || ''),
avatar: String(agency.avatar || agency.avatar_url || ''),
createdAtMS: Number(
agency.created_at_ms || agency.createdAtMs || 0
),
},
};
}
function normalizeStats(payload, range) {
var source = payload || {};
var summary = source.summary || {};
return {
range: source.range || range,
summary: {
total_earnings: Number(summary.total_earnings || 0),
gift_income: Number(summary.gift_income || 0),
share_income: Number(summary.share_income || 0),
total_hosts: Number(summary.total_hosts || 0),
gifted_host_count: Number(summary.gifted_host_count || 0),
},
hosts: Array.isArray(source.hosts)
? source.hosts.map(function (host) {
return {
membershipId: String(host.membership_id || ''),
userId: String(host.host_user_id || ''),
displayUserId: String(
host.display_user_id || host.host_user_id || ''
),
name: String(host.username || ''),
avatar: String(host.avatar || ''),
joinedAtMS: Number(host.joined_at_ms || 0),
diamondEarnings: Number(host.diamond_earnings || 0),
diamondExchanged: Number(host.diamond_exchanged || 0),
giftSenders: Number(host.gift_senders || 0),
onlineDurationMS: Number(
host.online_duration_ms || 0
),
validMicDurationMS: Number(
host.valid_mic_duration_ms || 0
),
validMicDays: Number(host.valid_mic_days || 0),
removable: host.removable !== false,
};
})
: [],
};
}
function mockData(range) {
return {
agency: {
id: '343434',
shortId: '343434',
name: 'Fami Stars Agency',
avatar: '',
createdAtMS: new Date(2025, 9, 12, 15, 0).getTime(),
},
contact: '+20 1524151515',
pointBalance: 5152051511212,
stats: normalizeStats(
{
summary: {
total_earnings: 10000,
gift_income: 10000,
share_income: 10000,
total_hosts: 1,
gifted_host_count: 1,
},
hosts: [
{
membership_id: '8001',
host_user_id: '3534',
display_user_id: '3534',
username: 'Host dada',
diamond_earnings: 343434,
diamond_exchanged: 343434,
gift_senders: 343434,
online_duration_ms: 165 * 60 * 1000,
valid_mic_duration_ms: 152 * 60 * 1000,
valid_mic_days: 12,
joined_at_ms: new Date(
2025,
4,
31,
15,
0
).getTime(),
},
],
},
range
),
errors: {},
};
}
function load(range) {
if (params().get('mock') === '1')
return Promise.resolve(mockData(range));
var api = window.HyAppAPI || {};
if (!api.user || !api.agencyCenter || !api.wallet) {
return Promise.reject(new Error('guild_api_unavailable'));
}
return Promise.all([
settled(api.user.me()),
settled(api.agencyCenter.overview()),
settled(api.wallet.balances(['POINT'])),
settled(
api.agencyCenter.stats({
start_date: range.startDate,
end_date: range.endDate,
})
),
]).then(function (results) {
// owner 身份和 agency 资料是页面最小事实;余额/统计允许独立降级,避免聚合服务故障遮住管理入口。
if (!results[0].ok) throw results[0].error;
if (!results[1].ok) throw results[1].error;
var overview = normalizeOverview(results[1].value);
var profile = normalizeProfile(results[0].value);
var point = results[2].ok
? firstBalance(results[2].value, 'POINT')
: null;
return {
agency: overview.agency,
contact: profile.contact,
pointBalance: point
? Number(
point.available_amount || point.availableAmount || 0
)
: null,
stats: results[3].ok
? normalizeStats(results[3].value, range)
: normalizeStats({}, range),
errors: {
balance: results[2].ok ? null : results[2].error,
stats: results[3].ok ? null : results[3].error,
},
};
});
}
function commandID(prefix) {
var random =
window.crypto && window.crypto.randomUUID
? window.crypto.randomUUID()
: Date.now() + '-' + Math.random().toString(16).slice(2);
return prefix + ':' + random;
}
function saveContact(value) {
if (params().get('mock') === '1') return Promise.resolve({});
return window.HyAppAPI.user.updateContact({ contact_info: value });
}
function inviteHost(displayUserID) {
if (params().get('mock') === '1') return Promise.resolve({});
var api = window.HyAppAPI;
// 邀请写接口只接受内部 user_id先通过公共解析接口把用户输入的展示 ID 转换,禁止前端猜测两种 ID 相同。
return api.user
.resolveDisplayUserID(displayUserID)
.then(function (payload) {
var user =
(payload && (payload.user || payload.profile)) ||
payload ||
{};
var userID = user.user_id || user.userId;
if (!userID) throw new Error('host_not_found');
return api.agencyCenter.inviteHost({
command_id: commandID('fami-agency-invite-host'),
target_user_id: String(userID),
});
});
}
function removeHost(hostUserID) {
if (params().get('mock') === '1') return Promise.resolve({});
return window.HyAppAPI.agencyCenter.removeHost(hostUserID, {
command_id: commandID('fami-agency-remove-host'),
});
}
window.HyGuild = window.HyGuild || {};
window.HyGuild.agencyCenter = {
load: load,
saveContact: saveContact,
inviteHost: inviteHost,
removeHost: removeHost,
};
})();

View File

@ -0,0 +1,161 @@
(function () {
'use strict';
function params() {
return new URLSearchParams(window.location.search || '');
}
function normalizeOverview(payload) {
var profile = (payload && payload.profile) || {};
var role = (payload && payload.bd_profile) || {};
return {
profile: {
userId: String(profile.user_id || role.user_id || ''),
displayUserId: String(
profile.display_user_id ||
profile.user_id ||
role.user_id ||
''
),
name: String(profile.username || ''),
avatar: String(profile.avatar || ''),
contact: String(
profile.contact_info || profile.contactInfo || ''
),
},
};
}
function normalizeStats(payload, range) {
return {
range: (payload && payload.range) || range,
agencies: Array.isArray(payload && payload.items)
? payload.items.map(function (item) {
var agency = item.agency || {};
var owner = item.owner || {};
return {
id: String(agency.agency_id || ''),
shortId: String(
agency.short_id || agency.agency_id || ''
),
name: String(agency.name || ''),
avatar: String(agency.avatar || ''),
ownerId: String(
owner.display_user_id || owner.user_id || ''
),
ownerName: String(owner.username || ''),
country: String(item.country || owner.country || ''),
totalHosts: Number(item.total_hosts || 0),
giftedHostCount: Number(item.gifted_host_count || 0),
diamondEarnings: Number(item.diamond_earnings || 0),
joinedAtMS: Number(item.joined_at_ms || 0),
};
})
: [],
};
}
function mockData(range) {
return {
profile: {
userId: '163028',
displayUserId: '163028',
name: 'dada',
avatar: '',
contact: '1586668885',
},
stats: normalizeStats(
{
items: [
{
agency: {
agency_id: '343434',
short_id: '343434',
name: 'Fami Stars',
},
owner: {
display_user_id: '3534',
username: 'Agency owner',
},
country: 'Saudi Arabia',
total_hosts: 12,
gifted_host_count: 8,
diamond_earnings: 5152051,
joined_at_ms: new Date(
2025,
4,
31,
15,
0
).getTime(),
},
],
},
range
),
};
}
function load(range) {
if (params().get('mock') === '1')
return Promise.resolve(mockData(range));
var api = window.HyAppAPI || {};
if (!api.bdCenter || !api.user) {
return Promise.reject(new Error('guild_api_unavailable'));
}
return Promise.all([
api.bdCenter.overview(),
api.bdCenter.stats({
start_date: range.startDate,
end_date: range.endDate,
}),
]).then(function (results) {
var overview = normalizeOverview(results[0]);
return {
profile: overview.profile,
stats: normalizeStats(results[1], range),
};
});
}
function commandID() {
var random =
window.crypto && window.crypto.randomUUID
? window.crypto.randomUUID()
: Date.now() + '-' + Math.random().toString(16).slice(2);
return 'fami-bd-invite-agency:' + random;
}
function saveContact(value) {
if (params().get('mock') === '1') return Promise.resolve({});
return window.HyAppAPI.user.updateContact({ contact_info: value });
}
function inviteAgency(displayUserID, agencyName) {
if (params().get('mock') === '1') return Promise.resolve({});
var api = window.HyAppAPI;
// BD 写接口使用内部 user_id展示 ID 解析由 gateway 完成,避免把短号误当主键。
return api.user
.resolveDisplayUserID(displayUserID)
.then(function (payload) {
var user =
(payload && (payload.user || payload.profile)) ||
payload ||
{};
var userID = user.user_id || user.userId;
if (!userID) throw new Error('agency_owner_not_found');
return api.bdCenter.inviteAgency({
command_id: commandID(),
target_user_id: String(userID),
agency_name: String(agencyName || '').trim(),
});
});
}
window.HyGuild = window.HyGuild || {};
window.HyGuild.bdCenter = {
load: load,
saveContact: saveContact,
inviteAgency: inviteAgency,
};
})();

View File

@ -0,0 +1,90 @@
(function () {
'use strict';
var PRESETS = [
'today',
'yesterday',
'last_7_days',
'last_30_days',
'this_month',
'last_month',
'custom',
];
function pad(value) {
return String(value).padStart(2, '0');
}
function dateText(date) {
return (
date.getFullYear() +
'-' +
pad(date.getMonth() + 1) +
'-' +
pad(date.getDate())
);
}
function localDate(value) {
if (value instanceof Date) {
return new Date(
value.getFullYear(),
value.getMonth(),
value.getDate()
);
}
var match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
var date = new Date(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3])
);
return Number.isNaN(date.getTime()) ? null : date;
}
function addDays(date, count) {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate() + count
);
}
function createRange(preset, options) {
var name = PRESETS.indexOf(preset) >= 0 ? preset : 'today';
var opts = options || {};
var now = localDate(opts.now || new Date());
var start = now;
var end = now;
if (name === 'yesterday') start = end = addDays(now, -1);
else if (name === 'last_7_days') start = addDays(now, -6);
else if (name === 'last_30_days') start = addDays(now, -29);
else if (name === 'this_month')
start = new Date(now.getFullYear(), now.getMonth(), 1);
else if (name === 'last_month') {
start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
end = new Date(now.getFullYear(), now.getMonth(), 0);
} else if (name === 'custom') {
start = localDate(opts.start);
end = localDate(opts.end);
if (!start || !end || start.getTime() > end.getTime()) {
throw new Error('Invalid custom date range');
}
}
// UI 和 API 都使用包含首尾的自然日;在线时长等跨日指标由服务端按业务时区切日,前端不自行累计秒数。
return Object.freeze({
preset: name,
startDate: dateText(start),
endDate: dateText(end),
});
}
window.HyGuild = window.HyGuild || {};
window.HyGuild.dateRange = {
presets: Object.freeze(PRESETS.slice()),
create: createRange,
};
})();

View File

@ -0,0 +1,111 @@
(function () {
'use strict';
var CONTEXT_QUERY_KEYS = [
'token',
'access_token',
'accessToken',
'env',
'language',
'lang',
'locale',
'app_lang',
'appLanguage',
'mock',
];
var product = null;
function normalizeSlug(value, field) {
var slug = String(value || '')
.trim()
.toLowerCase();
if (!/^[a-z][a-z0-9_-]*$/.test(slug)) {
throw new Error('Invalid guild product ' + field + ': ' + slug);
}
return slug;
}
function freezeArray(value) {
return Object.freeze(Array.isArray(value) ? value.slice() : []);
}
function defineProduct(config) {
var next = config || {};
var normalized = Object.freeze({
key: normalizeSlug(next.key, 'key'),
appCode: normalizeSlug(next.appCode, 'appCode'),
languages: freezeArray(next.languages),
features: Object.freeze(
Object.assign(
{
hostCenter: true,
agencyCenter: true,
bdCenter: true,
wallet: true,
},
next.features || {}
)
),
});
// 同一页面只能属于一个产品;静默覆盖会让后加载的品牌包改变请求租户,属于高风险串数据。
if (product && product.key !== normalized.key) {
throw new Error('Guild product already defined as ' + product.key);
}
product = normalized;
window.HyAppDefaultAppCode = normalized.appCode;
window.HyAppI18nSupported = normalized.languages;
document.documentElement.setAttribute(
'data-hy-product',
normalized.key
);
return product;
}
function getProduct() {
if (!product) throw new Error('Guild product is not defined');
return product;
}
function currentParams() {
var params = new URLSearchParams(window.location.search || '');
var hash = window.location.hash || '';
if (hash.indexOf('?') >= 0) {
new URLSearchParams(hash.split('?').slice(1).join('?')).forEach(
function (value, key) {
if (!params.has(key)) params.set(key, value);
}
);
}
return params;
}
function buildURL(path, extra) {
var target = new URL(path, window.location.href);
var source = currentParams();
// 页面跳转只继承认证、环境和语言上下文,不复制筛选、分页等页面状态,避免不同中心解释同名参数。
CONTEXT_QUERY_KEYS.forEach(function (key) {
if (source.has(key) && !target.searchParams.has(key)) {
target.searchParams.set(key, source.get(key));
}
});
target.searchParams.set('app_code', getProduct().appCode);
Object.keys(extra || {}).forEach(function (key) {
var value = extra[key];
if (value === undefined || value === null || value === '') {
target.searchParams.delete(key);
return;
}
target.searchParams.set(key, String(value));
});
return target.toString();
}
window.HyGuild = window.HyGuild || {};
window.HyGuild.product = {
define: defineProduct,
get: getProduct,
buildURL: buildURL,
};
})();

View File

@ -0,0 +1,196 @@
(function () {
'use strict';
var METRICS = [
{ key: 'diamond_earnings', format: 'count' },
{ key: 'diamond_exchanged', format: 'count' },
{ key: 'gift_senders', format: 'count' },
{ key: 'online_duration_ms', format: 'duration' },
{ key: 'valid_mic_duration_ms', format: 'duration' },
{ key: 'valid_mic_days', format: 'count' },
{ key: 'private_message_senders', format: 'count' },
{ key: 'new_followers', format: 'count' },
];
function params() {
return new URLSearchParams(window.location.search || '');
}
function settled(promise) {
return Promise.resolve(promise).then(
function (value) {
return { ok: true, value: value };
},
function (error) {
return { ok: false, error: error };
}
);
}
function firstBalance(payload, assetType) {
var items = Array.isArray(payload)
? payload
: (payload && (payload.balances || payload.items)) || [];
for (var i = 0; i < items.length; i += 1) {
var item = items[i] || {};
if (
String(
item.asset_type || item.assetType || ''
).toUpperCase() === assetType
) {
return item;
}
}
return null;
}
function normalizeProfile(payload) {
var profile =
(payload && (payload.profile || payload.user)) || payload || {};
return {
userId: String(profile.user_id || profile.userId || ''),
displayUserId: String(
profile.display_user_id ||
profile.displayUserId ||
profile.user_id ||
''
),
name: String(profile.username || profile.name || ''),
avatar: String(profile.avatar || profile.avatar_url || ''),
contact: String(profile.contact_info || profile.contactInfo || ''),
};
}
function normalizeAgency(payload) {
var agency =
payload && Object.prototype.hasOwnProperty.call(payload, 'agency')
? payload.agency
: payload;
if (!agency) return null;
return {
id: String(agency.agency_id || agency.agencyId || ''),
shortId: String(
agency.short_id ||
agency.shortId ||
agency.agency_id ||
agency.agencyId ||
''
),
name: String(agency.name || ''),
joinedAtMS: Number(agency.joined_at_ms || agency.joinedAtMs || 0),
};
}
function normalizeStats(payload, range) {
var source =
(payload && (payload.metrics || payload.stats)) || payload || {};
var metrics = {};
METRICS.forEach(function (definition) {
var value = source[definition.key];
metrics[definition.key] =
value === undefined || value === null ? null : Number(value);
});
return {
range: (payload && payload.range) || range,
metrics: metrics,
};
}
function mockData(range) {
return {
profile: {
userId: '163028',
displayUserId: '163028',
name: 'dada',
avatar: '',
contact: '1586668885',
},
agency: {
id: '163030',
shortId: '163030',
name: 'Fami Stars',
joinedAtMS: new Date(2025, 5, 12, 16, 0).getTime(),
},
pointBalance: 5152051511212,
stats: normalizeStats(
{
diamond_earnings: 50000,
diamond_exchanged: 50000,
gift_senders: 50000,
online_duration_ms: 53 * 60 * 1000,
valid_mic_duration_ms: 53 * 60 * 1000,
valid_mic_days: 12,
private_message_senders: 50000,
new_followers: 50000,
},
range
),
errors: {},
};
}
function load(range) {
if (params().get('mock') === '1') {
return Promise.resolve(mockData(range));
}
var api = window.HyAppAPI || {};
if (!api.user || !api.hostCenter || !api.wallet) {
return Promise.reject(new Error('guild_api_unavailable'));
}
return Promise.all([
settled(api.user.me()),
settled(api.hostCenter.agency()),
settled(api.wallet.balances(['POINT'])),
settled(
api.hostCenter.stats({
start_date: range.startDate,
end_date: range.endDate,
})
),
]).then(function (results) {
// 资料是页面身份校验的最低要求;其余读模型可以分别降级,不能因统计服务晚发布让联系方式和余额也不可见。
if (!results[0].ok) throw results[0].error;
var errors = {};
if (!results[1].ok) errors.agency = results[1].error;
if (!results[2].ok) errors.balance = results[2].error;
if (!results[3].ok) errors.stats = results[3].error;
var point = results[2].ok
? firstBalance(results[2].value, 'POINT')
: null;
return {
profile: normalizeProfile(results[0].value),
agency: results[1].ok
? normalizeAgency(results[1].value)
: null,
pointBalance: point
? Number(
point.available_amount || point.availableAmount || 0
)
: null,
stats: results[3].ok
? normalizeStats(results[3].value, range)
: normalizeStats({}, range),
errors: errors,
};
});
}
function saveContact(value) {
if (params().get('mock') === '1') {
return Promise.resolve({ contact_info: value });
}
var api = window.HyAppAPI || {};
if (!api.user || !api.user.updateContact) {
return Promise.reject(new Error('guild_api_unavailable'));
}
return api.user.updateContact({ contact_info: value });
}
window.HyGuild = window.HyGuild || {};
window.HyGuild.hostCenter = {
metrics: Object.freeze(METRICS.slice()),
load: load,
saveContact: saveContact,
};
})();

View File

@ -0,0 +1,48 @@
.guild-center {
min-height: 100vh;
background: var(--hy-theme-bg);
color: var(--hy-theme-text);
}
.guild-center__content {
display: grid;
gap: 14px;
width: min(100%, 720px);
margin: 0 auto;
padding: 16px max(16px, env(safe-area-inset-right))
max(28px, env(safe-area-inset-bottom))
max(16px, env(safe-area-inset-left));
}
.guild-card {
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-card);
background: var(--hy-theme-surface);
box-shadow: var(--hy-theme-shadow);
}
.guild-filter-bar {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.guild-filter-bar button {
min-height: 38px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-control);
background: var(--hy-theme-surface);
color: var(--hy-theme-text);
}
.guild-filter-bar button[aria-pressed='true'] {
border-color: var(--hy-theme-primary-strong);
background: var(--hy-theme-primary-soft);
color: var(--hy-theme-primary-deep);
}
@media (max-width: 420px) {
.guild-filter-bar {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}

View File

@ -0,0 +1,196 @@
(function () {
'use strict';
function query() {
return new URLSearchParams(window.location.search || '');
}
function commandID(prefix) {
var random =
window.crypto && window.crypto.randomUUID
? window.crypto.randomUUID()
: Date.now() + '-' + Math.random().toString(16).slice(2);
return prefix + ':' + random;
}
function normalizeBalance(value) {
value = value || {};
return {
available: Number(
value.available_amount || value.availableAmount || 0
),
frozen: Number(value.frozen_amount || value.frozenAmount || 0),
displayUSD: String(value.display_usd || value.displayUSD || '0.00'),
};
}
function normalizeSeller(value) {
value = value || {};
return {
userId: String(value.user_id || value.userId || ''),
displayUserId: String(
value.display_user_id || value.displayUserId || ''
),
nickname: String(value.nickname || ''),
avatar: String(value.avatar || ''),
sortOrder: Number(value.sort_order || value.sortOrder || 0),
pointAmount: Number(value.point_amount || value.pointAmount || 0),
sellerCoinAmount: Number(
value.seller_coin_amount || value.sellerCoinAmount || 0
),
serviceCountries:
value.service_country_codes || value.serviceCountryCodes || [],
};
}
function normalizeTransaction(value) {
value = value || {};
return {
id: String(value.transaction_id || value.transactionId || ''),
bizType: String(value.biz_type || value.bizType || ''),
availableDelta: Number(
value.available_delta || value.availableDelta || 0
),
counterpartyUserId: String(
value.counterparty_user_id || value.counterpartyUserId || ''
),
createdAtMS: Number(value.created_at_ms || value.createdAtMs || 0),
};
}
function normalizeOverview(value) {
value = value || {};
var address = value.withdraw_address || value.withdrawAddress || {};
return {
balance: normalizeBalance(value.balance),
pointsPerUSD: Number(
value.points_per_usd || value.pointsPerUSD || 0
),
feeBPS: Number(value.fee_bps || value.feeBPS || 0),
minimumPoints: Number(
value.minimum_points || value.minimumPoints || 0
),
policyInstance: String(
value.policy_instance_code || value.policyInstance || ''
),
address: String(
address.usdt_trc20_address || address.usdtTRC20Address || ''
),
sellers: (value.coin_sellers || value.coinSellers || [])
.map(normalizeSeller)
.filter(function (item) {
return (
item.userId &&
item.pointAmount > 0 &&
item.sellerCoinAmount > 0
);
}),
};
}
function normalizeHistory(value) {
var items = Array.isArray(value)
? value
: (value && (value.transactions || value.items)) || [];
return items.map(normalizeTransaction);
}
function mockData() {
return {
balance: {
available: 5152051511212,
frozen: 0,
displayUSD: '51520515.11',
},
pointsPerUSD: 100000,
feeBPS: 500,
minimumPoints: 1000000,
policyInstance: 'fami-guild-demo',
address: '',
sellers: [
{
userId: '34343',
displayUserId: '34343',
nickname: 'Coin Seller One',
avatar: '',
sortOrder: 1,
pointAmount: 100000,
sellerCoinAmount: 92000,
serviceCountries: ['SA', 'TR'],
},
{
userId: '34344',
displayUserId: '34344',
nickname: 'Coin Seller Two',
avatar: '',
sortOrder: 2,
pointAmount: 100000,
sellerCoinAmount: 90000,
serviceCountries: [],
},
],
history: [
{
id: 'demo-1',
bizType: 'point_transfer_to_coin_seller',
availableDelta: -1000000,
counterpartyUserId: '34343',
createdAtMS: Date.now() - 3600000,
},
],
};
}
function load() {
if (query().get('mock') === '1') return Promise.resolve(mockData());
var api = window.HyAppAPI && window.HyAppAPI.pointWallet;
if (!api)
return Promise.reject(new Error('point_wallet_api_unavailable'));
return Promise.all([
api.overview(),
api.history(30).catch(function () {
// 流水故障不应遮住余额和提现入口;页面会单独提示历史暂不可用。
return null;
}),
]).then(function (results) {
var data = normalizeOverview(results[0]);
data.history = normalizeHistory(results[1]);
data.historyUnavailable = results[1] === null;
return data;
});
}
function transferToCoinSeller(sellerUserID, pointAmount) {
if (query().get('mock') === '1') return Promise.resolve({});
return window.HyAppAPI.pointWallet.transferToCoinSeller({
command_id: commandID('fami-point-coin-seller'),
seller_user_id: String(sellerUserID || ''),
point_amount: Number(pointAmount || 0),
});
}
function withdrawUSDT(pointAmount, address) {
if (query().get('mock') === '1') return Promise.resolve({});
return window.HyAppAPI.pointWallet.withdrawUSDT({
command_id: commandID('fami-point-usdt'),
gross_point_amount: Number(pointAmount || 0),
usdt_trc20_address: String(address || '').trim(),
});
}
function saveAddress(address) {
if (query().get('mock') === '1') return Promise.resolve({});
// 地址写入继续复用通用工资钱包用户资料接口;资金冻结仍只由 POINT 提现接口完成。
return window.HyAppAPI.salaryWallet.saveWithdrawAddress({
usdt_trc20_address: String(address || '').trim(),
});
}
window.HyGuild = window.HyGuild || {};
window.HyGuild.wallet = {
load: load,
saveAddress: saveAddress,
transferToCoinSeller: transferToCoinSeller,
withdrawUSDT: withdrawUSDT,
};
})();

View File

@ -0,0 +1,139 @@
{
"fami_common.apply": "تطبيق",
"fami_common.back": "رجوع",
"fami_common.cancel": "إلغاء",
"fami_common.change_language": "تغيير اللغة",
"fami_common.diamond_balance": "رصيد الألماس",
"fami_common.edit": "تعديل",
"fami_common.save": "حفظ",
"fami_common.stats_unavailable": "الإحصاءات غير متاحة مؤقتًا.",
"fami_common.uid_prefix": "UID:",
"fami_common.withdraw": "سحب",
"fami_agency.action": "الإجراء",
"fami_agency.add_host": "إضافة مضيف",
"fami_agency.contact": "واتساب",
"fami_agency.contact_number": "رقم التواصل",
"fami_agency.contact_required": "أدخل رقم التواصل.",
"fami_agency.contact_save_failed": "تعذر حفظ جهة الاتصال.",
"fami_agency.contact_saved": "تم حفظ جهة الاتصال.",
"fami_agency.created_prefix": "تاريخ الإنشاء:",
"fami_agency.data_title": "بيانات الوكالة",
"fami_agency.edit_contact": "تعديل واتساب",
"fami_agency.gift_income": "دخل الهدايا",
"fami_agency.gifted_hosts": "المضيفون المتلقون للهدايا",
"fami_agency.host": "المضيف",
"fami_agency.host_count": "{count} مضيف",
"fami_agency.host_id": "معرّف المضيف",
"fami_agency.host_removed": "تمت إزالة المضيف.",
"fami_agency.id_prefix": "معرّف الوكالة:",
"fami_agency.invite_failed": "تعذر دعوة المضيف.",
"fami_agency.invite_hint": "أدخل معرّف العرض للمضيف. ينضم المضيف بعد قبول الدعوة.",
"fami_agency.invite_sent": "تم إرسال الدعوة. يجب على المضيف قبولها.",
"fami_agency.joined_at": "تاريخ الانضمام",
"fami_agency.load_failed": "تعذر تحميل مركز الوكالة.",
"fami_agency.no_hosts": "لا يوجد مضيفون بعد.",
"fami_agency.owner": "المالك",
"fami_agency.remove": "إزالة",
"fami_agency.remove_confirm": "إزالة {name} من هذه الوكالة؟ ستبقى الأرباح السابقة دون تغيير.",
"fami_agency.remove_failed": "تعذرت إزالة المضيف.",
"fami_agency.remove_host": "إزالة المضيف",
"fami_agency.send_invite": "إرسال الدعوة",
"fami_agency.share_income": "دخل الحصة",
"fami_agency.tab_agency": "بيانات الوكالة",
"fami_agency.tab_hosts": "بيانات المضيفين",
"fami_agency.title": "مركز الوكالة",
"fami_agency.total_earnings": "إجمالي الأرباح",
"fami_agency.total_hosts": "إجمالي المضيفين",
"fami_bd.add_agency": "إضافة وكالة",
"fami_bd.agency": "الوكالة",
"fami_bd.agency_data": "بيانات الوكالات",
"fami_bd.agency_name": "اسم الوكالة",
"fami_bd.contact": "واتساب",
"fami_bd.country": "الدولة",
"fami_bd.diamond_earnings": "إجمالي أرباح ألماس الوكالة",
"fami_bd.edit_contact": "تعديل واتساب",
"fami_bd.invite_failed": "تعذرت دعوة الوكالة.",
"fami_bd.invite_hint": "أدخل معرّف العرض لمالك الوكالة المستقبلي. تُنشأ الوكالة بعد قبول الدعوة.",
"fami_bd.invite_sent": "تم إرسال الدعوة. تُنشأ الوكالة بعد القبول.",
"fami_bd.joined_at": "تاريخ الانضمام",
"fami_bd.load_failed": "تعذر تحميل مركز BD.",
"fami_bd.no_agencies": "لا توجد وكالات بعد.",
"fami_bd.owner_id": "معرّف المالك",
"fami_bd.title": "مركز BD",
"fami_host.agency_id_prefix": "معرّف الوكالة:",
"fami_host.back": "رجوع",
"fami_host.change_language": "تغيير اللغة",
"fami_host.contact": "واتساب",
"fami_host.contact_number": "رقم التواصل",
"fami_host.contact_required": "أدخل رقم التواصل.",
"fami_host.contact_save_failed": "تعذر حفظ جهة الاتصال.",
"fami_host.contact_saved": "تم حفظ جهة الاتصال.",
"fami_host.data_details": "تفاصيل البيانات",
"fami_host.diamond_balance": "رصيد الألماس",
"fami_host.edit": "تعديل",
"fami_host.edit_contact": "تعديل واتساب",
"fami_host.joined_at_prefix": "تاريخ الانضمام:",
"fami_host.load_failed": "تعذر تحميل مركز المضيف.",
"fami_host.stats_unavailable": "الإحصاءات غير متاحة مؤقتًا.",
"fami_host.title": "مركز المضيف",
"fami_host.uid_prefix": "UID:",
"fami_host.withdraw": "سحب",
"fami_metric.diamond_earnings": "أرباح الألماس",
"fami_metric.diamond_exchanged": "الألماس المحوّل",
"fami_metric.gift_senders": "مرسلو الهدايا",
"fami_metric.new_followers": "متابعون جدد",
"fami_metric.online_duration": "مدة الاتصال",
"fami_metric.private_message_senders": "مرسلو الرسائل الخاصة",
"fami_metric.valid_mic_days": "أيام الميكروفون الفعالة",
"fami_metric.valid_mic_duration": "مدة الميكروفون الفعالة",
"fami_range.custom": "مخصص",
"fami_range.custom_title": "نطاق تاريخ مخصص",
"fami_range.end": "تاريخ النهاية",
"fami_range.invalid": "اختر نطاق تاريخ صالحًا.",
"fami_range.label": "نطاق التاريخ",
"fami_range.last_30_days": "آخر 30 يومًا",
"fami_range.last_7_days": "آخر 7 أيام",
"fami_range.last_month": "الشهر الماضي",
"fami_range.start": "تاريخ البداية",
"fami_range.this_month": "هذا الشهر",
"fami_range.today": "اليوم",
"fami_range.yesterday": "أمس",
"fami_unit.day_short": "ي",
"fami_unit.hour_short": "س",
"fami_unit.minute_short": "د",
"fami_wallet.address_placeholder": "T...",
"fami_wallet.address_save_failed": "تعذر حفظ العنوان.",
"fami_wallet.address_saved": "تم حفظ العنوان.",
"fami_wallet.all": "الكل",
"fami_wallet.amount": "مبلغ السحب",
"fami_wallet.amount_placeholder": "أدخل مبلغ POINT",
"fami_wallet.choose_seller": "اختر تاجر العملات",
"fami_wallet.estimated_receive": "الاستلام المتوقع",
"fami_wallet.history": "سجل السحب",
"fami_wallet.history_unavailable": "السجل غير متاح مؤقتًا.",
"fami_wallet.invalid_address": "أدخل عنوان TRC20 صالحًا.",
"fami_wallet.invalid_amount": "أدخل مبلغ POINT متاحًا.",
"fami_wallet.load_failed": "تعذر تحميل المحفظة.",
"fami_wallet.minimum_amount": "الحد الأدنى من POINT:",
"fami_wallet.no_address": "لم يتم حفظ عنوان USDT.",
"fami_wallet.no_history": "لا توجد سجلات سحب",
"fami_wallet.no_sellers": "لا يوجد تجار مؤهلون",
"fami_wallet.no_sellers_hint": "يعتمد التوفر على بلدك والقائمة البيضاء في لوحة الإدارة.",
"fami_wallet.point_balance": "رصيد POINT",
"fami_wallet.point_transaction": "معاملة POINT",
"fami_wallet.policy_caption": "يتم تعيين السعر والرسوم من لوحة الإدارة.",
"fami_wallet.save_address": "حفظ العنوان",
"fami_wallet.saved_address": "العنوان المحفوظ:",
"fami_wallet.seller_coin": "عملات التاجر",
"fami_wallet.seller_tab": "تاجر العملات",
"fami_wallet.seller_withdrawal": "سحب عبر تاجر العملات",
"fami_wallet.submit_failed": "تعذر إرسال طلب السحب.",
"fami_wallet.submitted": "تم إرسال طلب السحب.",
"fami_wallet.title": "سحب",
"fami_wallet.trc20_address": "عنوان TRC20",
"fami_wallet.usdt_address": "عنوان USDT TRC20",
"fami_wallet.usdt_tab": "USDT",
"fami_wallet.usdt_warning": "يتم إرسال السحب فقط إلى عنوان TRC20 المحفوظ.",
"fami_wallet.usdt_withdrawal": "سحب USDT",
"fami_wallet.withdraw_now": "اسحب الآن"
}

View File

@ -0,0 +1,139 @@
{
"fami_common.apply": "Apply",
"fami_common.back": "Back",
"fami_common.cancel": "Cancel",
"fami_common.change_language": "Change language",
"fami_common.diamond_balance": "Diamond balance",
"fami_common.edit": "Edit",
"fami_common.save": "Save",
"fami_common.stats_unavailable": "Statistics are temporarily unavailable.",
"fami_common.uid_prefix": "UID:",
"fami_common.withdraw": "Withdraw",
"fami_agency.action": "Action",
"fami_agency.add_host": "Add host",
"fami_agency.contact": "WhatsApp",
"fami_agency.contact_number": "Contact number",
"fami_agency.contact_required": "Enter a contact number.",
"fami_agency.contact_save_failed": "Unable to save contact.",
"fami_agency.contact_saved": "Contact saved.",
"fami_agency.created_prefix": "Created:",
"fami_agency.data_title": "Agency data",
"fami_agency.edit_contact": "Edit WhatsApp",
"fami_agency.gift_income": "Gift income",
"fami_agency.gifted_hosts": "Gifted hosts",
"fami_agency.host": "Host",
"fami_agency.host_count": "{count} hosts",
"fami_agency.host_id": "Host ID",
"fami_agency.host_removed": "Host removed.",
"fami_agency.id_prefix": "Agency ID:",
"fami_agency.invite_failed": "Unable to invite host.",
"fami_agency.invite_hint": "Enter the host display ID. The host joins after accepting the invitation.",
"fami_agency.invite_sent": "Invitation sent. The host must accept it.",
"fami_agency.joined_at": "Joined",
"fami_agency.load_failed": "Unable to load Agency Center.",
"fami_agency.no_hosts": "No hosts yet.",
"fami_agency.owner": "Owner",
"fami_agency.remove": "Remove",
"fami_agency.remove_confirm": "Remove {name} from this agency? Historical earnings will remain unchanged.",
"fami_agency.remove_failed": "Unable to remove host.",
"fami_agency.remove_host": "Remove host",
"fami_agency.send_invite": "Send invite",
"fami_agency.share_income": "Share income",
"fami_agency.tab_agency": "Agency data",
"fami_agency.tab_hosts": "Host data",
"fami_agency.title": "Agency Center",
"fami_agency.total_earnings": "Total earnings",
"fami_agency.total_hosts": "Total hosts",
"fami_bd.add_agency": "Add agency",
"fami_bd.agency": "Agency",
"fami_bd.agency_data": "Agency data",
"fami_bd.agency_name": "Agency name",
"fami_bd.contact": "WhatsApp",
"fami_bd.country": "Country",
"fami_bd.diamond_earnings": "Agency diamond earnings",
"fami_bd.edit_contact": "Edit WhatsApp",
"fami_bd.invite_failed": "Unable to invite agency.",
"fami_bd.invite_hint": "Enter the future agency owner's display ID. The agency is created after the invitation is accepted.",
"fami_bd.invite_sent": "Invitation sent. The agency is created after acceptance.",
"fami_bd.joined_at": "Joined",
"fami_bd.load_failed": "Unable to load BD Center.",
"fami_bd.no_agencies": "No agencies yet.",
"fami_bd.owner_id": "Owner ID",
"fami_bd.title": "BD Center",
"fami_host.agency_id_prefix": "Agency ID:",
"fami_host.back": "Back",
"fami_host.change_language": "Change language",
"fami_host.contact": "WhatsApp",
"fami_host.contact_number": "Contact number",
"fami_host.contact_required": "Enter a contact number.",
"fami_host.contact_save_failed": "Unable to save contact.",
"fami_host.contact_saved": "Contact saved.",
"fami_host.data_details": "Data details",
"fami_host.diamond_balance": "Diamond balance",
"fami_host.edit": "Edit",
"fami_host.edit_contact": "Edit WhatsApp",
"fami_host.joined_at_prefix": "Joined:",
"fami_host.load_failed": "Unable to load Host Center.",
"fami_host.stats_unavailable": "Statistics are temporarily unavailable.",
"fami_host.title": "Host Center",
"fami_host.uid_prefix": "UID:",
"fami_host.withdraw": "Withdraw",
"fami_metric.diamond_earnings": "Diamond earnings",
"fami_metric.diamond_exchanged": "Diamonds exchanged",
"fami_metric.gift_senders": "Gift senders",
"fami_metric.new_followers": "New followers",
"fami_metric.online_duration": "Online duration",
"fami_metric.private_message_senders": "Private message users",
"fami_metric.valid_mic_days": "Valid mic days",
"fami_metric.valid_mic_duration": "Valid mic duration",
"fami_range.custom": "Custom",
"fami_range.custom_title": "Custom date range",
"fami_range.end": "End date",
"fami_range.invalid": "Choose a valid date range.",
"fami_range.label": "Date range",
"fami_range.last_30_days": "Last 30 days",
"fami_range.last_7_days": "Last 7 days",
"fami_range.last_month": "Last month",
"fami_range.start": "Start date",
"fami_range.this_month": "This month",
"fami_range.today": "Today",
"fami_range.yesterday": "Yesterday",
"fami_unit.day_short": "d",
"fami_unit.hour_short": "h",
"fami_unit.minute_short": "m",
"fami_wallet.address_placeholder": "T...",
"fami_wallet.address_save_failed": "Unable to save address.",
"fami_wallet.address_saved": "Address saved.",
"fami_wallet.all": "All",
"fami_wallet.amount": "Withdrawal amount",
"fami_wallet.amount_placeholder": "Enter POINT amount",
"fami_wallet.choose_seller": "Choose coin seller",
"fami_wallet.estimated_receive": "Estimated receive",
"fami_wallet.history": "Withdrawal history",
"fami_wallet.history_unavailable": "History is temporarily unavailable.",
"fami_wallet.invalid_address": "Enter a valid TRC20 address.",
"fami_wallet.invalid_amount": "Enter an available POINT amount.",
"fami_wallet.load_failed": "Unable to load wallet.",
"fami_wallet.minimum_amount": "Minimum POINT amount:",
"fami_wallet.no_address": "No USDT address saved.",
"fami_wallet.no_history": "No withdrawal records",
"fami_wallet.no_sellers": "No eligible coin sellers",
"fami_wallet.no_sellers_hint": "Availability depends on your country and the Admin whitelist.",
"fami_wallet.point_balance": "POINT balance",
"fami_wallet.point_transaction": "POINT transaction",
"fami_wallet.policy_caption": "Rate and fee are set by Admin.",
"fami_wallet.save_address": "Save address",
"fami_wallet.saved_address": "Saved address:",
"fami_wallet.seller_coin": "seller coins",
"fami_wallet.seller_tab": "Coin seller",
"fami_wallet.seller_withdrawal": "Coin seller withdrawal",
"fami_wallet.submit_failed": "Unable to submit withdrawal.",
"fami_wallet.submitted": "Withdrawal submitted.",
"fami_wallet.title": "Withdraw",
"fami_wallet.trc20_address": "TRC20 address",
"fami_wallet.usdt_address": "USDT TRC20 address",
"fami_wallet.usdt_tab": "USDT",
"fami_wallet.usdt_warning": "Withdrawals are sent only to your saved TRC20 address.",
"fami_wallet.usdt_withdrawal": "USDT withdrawal",
"fami_wallet.withdraw_now": "Withdraw now"
}

View File

@ -0,0 +1,139 @@
{
"fami_common.apply": "Aplicar",
"fami_common.back": "Volver",
"fami_common.cancel": "Cancelar",
"fami_common.change_language": "Cambiar idioma",
"fami_common.diamond_balance": "Saldo de diamantes",
"fami_common.edit": "Editar",
"fami_common.save": "Guardar",
"fami_common.stats_unavailable": "Las estadísticas no están disponibles temporalmente.",
"fami_common.uid_prefix": "UID:",
"fami_common.withdraw": "Retirar",
"fami_agency.action": "Acción",
"fami_agency.add_host": "Añadir anfitrión",
"fami_agency.contact": "WhatsApp",
"fami_agency.contact_number": "Número de contacto",
"fami_agency.contact_required": "Introduce un número de contacto.",
"fami_agency.contact_save_failed": "No se pudo guardar el contacto.",
"fami_agency.contact_saved": "Contacto guardado.",
"fami_agency.created_prefix": "Creada:",
"fami_agency.data_title": "Datos de la agencia",
"fami_agency.edit_contact": "Editar WhatsApp",
"fami_agency.gift_income": "Ingresos por regalos",
"fami_agency.gifted_hosts": "Anfitriones con regalos",
"fami_agency.host": "Anfitrión",
"fami_agency.host_count": "{count} anfitriones",
"fami_agency.host_id": "ID del anfitrión",
"fami_agency.host_removed": "Anfitrión eliminado.",
"fami_agency.id_prefix": "ID de agencia:",
"fami_agency.invite_failed": "No se pudo invitar al anfitrión.",
"fami_agency.invite_hint": "Introduce el ID visible del anfitrión. Se unirá después de aceptar la invitación.",
"fami_agency.invite_sent": "Invitación enviada. El anfitrión debe aceptarla.",
"fami_agency.joined_at": "Ingreso",
"fami_agency.load_failed": "No se pudo cargar el Centro de agencia.",
"fami_agency.no_hosts": "Aún no hay anfitriones.",
"fami_agency.owner": "Propietario",
"fami_agency.remove": "Eliminar",
"fami_agency.remove_confirm": "¿Eliminar a {name} de esta agencia? Los ingresos históricos no cambiarán.",
"fami_agency.remove_failed": "No se pudo eliminar al anfitrión.",
"fami_agency.remove_host": "Eliminar anfitrión",
"fami_agency.send_invite": "Enviar invitación",
"fami_agency.share_income": "Ingresos compartidos",
"fami_agency.tab_agency": "Datos de la agencia",
"fami_agency.tab_hosts": "Datos de anfitriones",
"fami_agency.title": "Centro de agencia",
"fami_agency.total_earnings": "Ganancias totales",
"fami_agency.total_hosts": "Total de anfitriones",
"fami_bd.add_agency": "Añadir agencia",
"fami_bd.agency": "Agencia",
"fami_bd.agency_data": "Datos de agencias",
"fami_bd.agency_name": "Nombre de agencia",
"fami_bd.contact": "WhatsApp",
"fami_bd.country": "País",
"fami_bd.diamond_earnings": "Ganancias de diamantes de la agencia",
"fami_bd.edit_contact": "Editar WhatsApp",
"fami_bd.invite_failed": "No se pudo invitar a la agencia.",
"fami_bd.invite_hint": "Introduce el ID visible del futuro propietario. La agencia se crea después de aceptar la invitación.",
"fami_bd.invite_sent": "Invitación enviada. La agencia se crea después de aceptarla.",
"fami_bd.joined_at": "Ingreso",
"fami_bd.load_failed": "No se pudo cargar el Centro BD.",
"fami_bd.no_agencies": "Aún no hay agencias.",
"fami_bd.owner_id": "ID del propietario",
"fami_bd.title": "Centro BD",
"fami_host.agency_id_prefix": "ID de agencia:",
"fami_host.back": "Volver",
"fami_host.change_language": "Cambiar idioma",
"fami_host.contact": "WhatsApp",
"fami_host.contact_number": "Número de contacto",
"fami_host.contact_required": "Introduce un número de contacto.",
"fami_host.contact_save_failed": "No se pudo guardar el contacto.",
"fami_host.contact_saved": "Contacto guardado.",
"fami_host.data_details": "Detalles de datos",
"fami_host.diamond_balance": "Saldo de diamantes",
"fami_host.edit": "Editar",
"fami_host.edit_contact": "Editar WhatsApp",
"fami_host.joined_at_prefix": "Ingreso:",
"fami_host.load_failed": "No se pudo cargar el Centro de anfitrión.",
"fami_host.stats_unavailable": "Las estadísticas no están disponibles temporalmente.",
"fami_host.title": "Centro de anfitrión",
"fami_host.uid_prefix": "UID:",
"fami_host.withdraw": "Retirar",
"fami_metric.diamond_earnings": "Ganancias de diamantes",
"fami_metric.diamond_exchanged": "Diamantes canjeados",
"fami_metric.gift_senders": "Remitentes de regalos",
"fami_metric.new_followers": "Nuevos seguidores",
"fami_metric.online_duration": "Tiempo en línea",
"fami_metric.private_message_senders": "Usuarios de mensajes privados",
"fami_metric.valid_mic_days": "Días de micrófono válidos",
"fami_metric.valid_mic_duration": "Tiempo de micrófono válido",
"fami_range.custom": "Personalizado",
"fami_range.custom_title": "Rango de fechas personalizado",
"fami_range.end": "Fecha final",
"fami_range.invalid": "Elige un rango de fechas válido.",
"fami_range.label": "Rango de fechas",
"fami_range.last_30_days": "Últimos 30 días",
"fami_range.last_7_days": "Últimos 7 días",
"fami_range.last_month": "Mes pasado",
"fami_range.start": "Fecha inicial",
"fami_range.this_month": "Este mes",
"fami_range.today": "Hoy",
"fami_range.yesterday": "Ayer",
"fami_unit.day_short": "d",
"fami_unit.hour_short": "h",
"fami_unit.minute_short": "min",
"fami_wallet.address_placeholder": "T...",
"fami_wallet.address_save_failed": "No se pudo guardar la dirección.",
"fami_wallet.address_saved": "Dirección guardada.",
"fami_wallet.all": "Todo",
"fami_wallet.amount": "Importe de retiro",
"fami_wallet.amount_placeholder": "Introduce el importe en POINT",
"fami_wallet.choose_seller": "Elige un vendedor de monedas",
"fami_wallet.estimated_receive": "Recepción estimada",
"fami_wallet.history": "Historial de retiros",
"fami_wallet.history_unavailable": "El historial no está disponible temporalmente.",
"fami_wallet.invalid_address": "Introduce una dirección TRC20 válida.",
"fami_wallet.invalid_amount": "Introduce un importe POINT disponible.",
"fami_wallet.load_failed": "No se pudo cargar la cartera.",
"fami_wallet.minimum_amount": "Importe mínimo de POINT:",
"fami_wallet.no_address": "No hay dirección USDT guardada.",
"fami_wallet.no_history": "No hay registros de retiro",
"fami_wallet.no_sellers": "No hay vendedores elegibles",
"fami_wallet.no_sellers_hint": "La disponibilidad depende de tu país y de la lista blanca de Admin.",
"fami_wallet.point_balance": "Saldo POINT",
"fami_wallet.point_transaction": "Transacción POINT",
"fami_wallet.policy_caption": "La tasa y la comisión se configuran en Admin.",
"fami_wallet.save_address": "Guardar dirección",
"fami_wallet.saved_address": "Dirección guardada:",
"fami_wallet.seller_coin": "monedas del vendedor",
"fami_wallet.seller_tab": "Vendedor de monedas",
"fami_wallet.seller_withdrawal": "Retiro con vendedor",
"fami_wallet.submit_failed": "No se pudo enviar el retiro.",
"fami_wallet.submitted": "Retiro enviado.",
"fami_wallet.title": "Retirar",
"fami_wallet.trc20_address": "Dirección TRC20",
"fami_wallet.usdt_address": "Dirección USDT TRC20",
"fami_wallet.usdt_tab": "USDT",
"fami_wallet.usdt_warning": "Los retiros se envían solo a tu dirección TRC20 guardada.",
"fami_wallet.usdt_withdrawal": "Retiro USDT",
"fami_wallet.withdraw_now": "Retirar ahora"
}

View File

@ -0,0 +1,139 @@
{
"fami_common.apply": "Uygula",
"fami_common.back": "Geri",
"fami_common.cancel": "İptal",
"fami_common.change_language": "Dili değiştir",
"fami_common.diamond_balance": "Elmas bakiyesi",
"fami_common.edit": "Düzenle",
"fami_common.save": "Kaydet",
"fami_common.stats_unavailable": "İstatistikler geçici olarak kullanılamıyor.",
"fami_common.uid_prefix": "UID:",
"fami_common.withdraw": "Çek",
"fami_agency.action": "İşlem",
"fami_agency.add_host": "Host ekle",
"fami_agency.contact": "WhatsApp",
"fami_agency.contact_number": "İletişim numarası",
"fami_agency.contact_required": "Bir iletişim numarası girin.",
"fami_agency.contact_save_failed": "İletişim bilgisi kaydedilemedi.",
"fami_agency.contact_saved": "İletişim bilgisi kaydedildi.",
"fami_agency.created_prefix": "Oluşturulma:",
"fami_agency.data_title": "Ajans verileri",
"fami_agency.edit_contact": "WhatsApp'ı düzenle",
"fami_agency.gift_income": "Hediye geliri",
"fami_agency.gifted_hosts": "Hediye alan hostlar",
"fami_agency.host": "Host",
"fami_agency.host_count": "{count} host",
"fami_agency.host_id": "Host ID",
"fami_agency.host_removed": "Host kaldırıldı.",
"fami_agency.id_prefix": "Ajans ID:",
"fami_agency.invite_failed": "Host davet edilemedi.",
"fami_agency.invite_hint": "Hostun görünen ID'sini girin. Host daveti kabul ettikten sonra katılır.",
"fami_agency.invite_sent": "Davet gönderildi. Hostun kabul etmesi gerekir.",
"fami_agency.joined_at": "Katılım",
"fami_agency.load_failed": "Agency Center yüklenemedi.",
"fami_agency.no_hosts": "Henüz host yok.",
"fami_agency.owner": "Sahip",
"fami_agency.remove": "Kaldır",
"fami_agency.remove_confirm": "{name} bu ajanstan kaldırılsın mı? Geçmiş kazançlar değişmeyecek.",
"fami_agency.remove_failed": "Host kaldırılamadı.",
"fami_agency.remove_host": "Hostu kaldır",
"fami_agency.send_invite": "Davet gönder",
"fami_agency.share_income": "Pay geliri",
"fami_agency.tab_agency": "Ajans verileri",
"fami_agency.tab_hosts": "Host verileri",
"fami_agency.title": "Agency Center",
"fami_agency.total_earnings": "Toplam kazanç",
"fami_agency.total_hosts": "Toplam host",
"fami_bd.add_agency": "Ajans ekle",
"fami_bd.agency": "Ajans",
"fami_bd.agency_data": "Ajans verileri",
"fami_bd.agency_name": "Ajans adı",
"fami_bd.contact": "WhatsApp",
"fami_bd.country": "Ülke",
"fami_bd.diamond_earnings": "Ajans elmas kazancı",
"fami_bd.edit_contact": "WhatsApp'ı düzenle",
"fami_bd.invite_failed": "Ajans davet edilemedi.",
"fami_bd.invite_hint": "Gelecekteki ajans sahibinin görünen ID'sini girin. Ajans davet kabul edilince oluşturulur.",
"fami_bd.invite_sent": "Davet gönderildi. Ajans kabulden sonra oluşturulur.",
"fami_bd.joined_at": "Katılım",
"fami_bd.load_failed": "BD Center yüklenemedi.",
"fami_bd.no_agencies": "Henüz ajans yok.",
"fami_bd.owner_id": "Sahip ID",
"fami_bd.title": "BD Center",
"fami_host.agency_id_prefix": "Ajans ID:",
"fami_host.back": "Geri",
"fami_host.change_language": "Dili değiştir",
"fami_host.contact": "WhatsApp",
"fami_host.contact_number": "İletişim numarası",
"fami_host.contact_required": "Bir iletişim numarası girin.",
"fami_host.contact_save_failed": "İletişim bilgisi kaydedilemedi.",
"fami_host.contact_saved": "İletişim bilgisi kaydedildi.",
"fami_host.data_details": "Veri ayrıntıları",
"fami_host.diamond_balance": "Elmas bakiyesi",
"fami_host.edit": "Düzenle",
"fami_host.edit_contact": "WhatsApp'ı düzenle",
"fami_host.joined_at_prefix": "Katılım:",
"fami_host.load_failed": "Host Center yüklenemedi.",
"fami_host.stats_unavailable": "İstatistikler geçici olarak kullanılamıyor.",
"fami_host.title": "Host Center",
"fami_host.uid_prefix": "UID:",
"fami_host.withdraw": "Çek",
"fami_metric.diamond_earnings": "Elmas kazancı",
"fami_metric.diamond_exchanged": "Çevrilen elmas",
"fami_metric.gift_senders": "Hediye gönderenler",
"fami_metric.new_followers": "Yeni takipçiler",
"fami_metric.online_duration": "Çevrimiçi süre",
"fami_metric.private_message_senders": "Özel mesaj kullanıcıları",
"fami_metric.valid_mic_days": "Geçerli mikrofon günleri",
"fami_metric.valid_mic_duration": "Geçerli mikrofon süresi",
"fami_range.custom": "Özel",
"fami_range.custom_title": "Özel tarih aralığı",
"fami_range.end": "Bitiş tarihi",
"fami_range.invalid": "Geçerli bir tarih aralığı seçin.",
"fami_range.label": "Tarih aralığı",
"fami_range.last_30_days": "Son 30 gün",
"fami_range.last_7_days": "Son 7 gün",
"fami_range.last_month": "Geçen ay",
"fami_range.start": "Başlangıç tarihi",
"fami_range.this_month": "Bu ay",
"fami_range.today": "Bugün",
"fami_range.yesterday": "Dün",
"fami_unit.day_short": "g",
"fami_unit.hour_short": "sa",
"fami_unit.minute_short": "dk",
"fami_wallet.address_placeholder": "T...",
"fami_wallet.address_save_failed": "Adres kaydedilemedi.",
"fami_wallet.address_saved": "Adres kaydedildi.",
"fami_wallet.all": "Tümü",
"fami_wallet.amount": "Çekim tutarı",
"fami_wallet.amount_placeholder": "POINT tutarını girin",
"fami_wallet.choose_seller": "Coin satıcısı seçin",
"fami_wallet.estimated_receive": "Tahmini alınacak",
"fami_wallet.history": "Çekim geçmişi",
"fami_wallet.history_unavailable": "Geçmiş geçici olarak kullanılamıyor.",
"fami_wallet.invalid_address": "Geçerli bir TRC20 adresi girin.",
"fami_wallet.invalid_amount": "Kullanılabilir bir POINT tutarı girin.",
"fami_wallet.load_failed": "Cüzdan yüklenemedi.",
"fami_wallet.minimum_amount": "Minimum POINT tutarı:",
"fami_wallet.no_address": "Kayıtlı USDT adresi yok.",
"fami_wallet.no_history": "Çekim kaydı yok",
"fami_wallet.no_sellers": "Uygun coin satıcısı yok",
"fami_wallet.no_sellers_hint": "Kullanılabilirlik ülkenize ve Admin beyaz listesine bağlıdır.",
"fami_wallet.point_balance": "POINT bakiyesi",
"fami_wallet.point_transaction": "POINT işlemi",
"fami_wallet.policy_caption": "Kur ve ücret Admin tarafından ayarlanır.",
"fami_wallet.save_address": "Adresi kaydet",
"fami_wallet.saved_address": "Kayıtlı adres:",
"fami_wallet.seller_coin": "satıcı coini",
"fami_wallet.seller_tab": "Coin satıcısı",
"fami_wallet.seller_withdrawal": "Coin satıcısına çekim",
"fami_wallet.submit_failed": "Çekim gönderilemedi.",
"fami_wallet.submitted": "Çekim gönderildi.",
"fami_wallet.title": "Çekim",
"fami_wallet.trc20_address": "TRC20 adresi",
"fami_wallet.usdt_address": "USDT TRC20 adresi",
"fami_wallet.usdt_tab": "USDT",
"fami_wallet.usdt_warning": "Çekimler yalnızca kayıtlı TRC20 adresinize gönderilir.",
"fami_wallet.usdt_withdrawal": "USDT çekimi",
"fami_wallet.withdraw_now": "Şimdi çek"
}

View File

@ -0,0 +1,33 @@
(function () {
'use strict';
var productScriptURL =
document.currentScript && document.currentScript.src
? document.currentScript.src
: window.location.href;
if (!window.HyGuild || !window.HyGuild.product) {
throw new Error('Guild product context must load before Fami');
}
// 产品清单是 Fami 页面唯一的租户默认值来源;功能默认全开,权限是否可用仍以后端 capability 为准。
window.HyGuild.product.define({
key: 'fami',
appCode: 'fami',
languages: ['en', 'ar', 'tr', 'es'],
features: {
hostCenter: true,
agencyCenter: true,
bdCenter: true,
wallet: true,
coinSellerWithdraw: true,
usdtWithdraw: true,
},
});
// 产品语言包和 common/locales 分开发布,后续新增品牌无需把整套页面文案塞回全站公共 JSON。
window.HyAppI18nExtraSources = window.HyAppI18nExtraSources || [];
window.HyAppI18nExtraSources.push(
new URL('locales/', productScriptURL).toString()
);
})();

View File

@ -8,6 +8,11 @@
"aslan:webview": "node scripts/aslan_flutter_webview.js",
"test:manager-center": "node scripts/test_manager_center_resource_pagination.js",
"test:recharge-v5pay": "node scripts/test_recharge_v5pay.js",
"test:guild-architecture": "node scripts/test_guild_product_architecture.js",
"test:fami-host-center": "node scripts/test_fami_host_center.js",
"test:fami-agency-center": "node scripts/test_fami_agency_center.js",
"test:fami-bd-center": "node scripts/test_fami_bd_center.js",
"test:fami-wallet": "node scripts/test_fami_wallet.js",
"format": "prettier --write \"**/*.{js,html,css,json,md}\"",
"format:check": "prettier --check \"**/*.{js,html,css,json,md}\""
},

View File

@ -0,0 +1,185 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.join(__dirname, '..');
function apiResponse(data) {
return {
headers: { get: () => '' },
ok: true,
status: 200,
statusText: 'OK',
text: () => Promise.resolve(JSON.stringify({ code: 'OK', data })),
};
}
function harness(search = '') {
const requests = [];
const location = {
hash: '',
hostname: 'h5.global-interaction.com',
href: `https://h5.global-interaction.com/guild/fami/agency-center/${search}`,
origin: 'https://h5.global-interaction.com',
pathname: '/guild/fami/agency-center/',
search,
};
const storage = new Map();
const window = {
atob: (value) => Buffer.from(value, 'base64').toString('binary'),
crypto: { randomUUID: () => 'fixed-uuid' },
document: {
currentScript: null,
documentElement: { setAttribute: () => {} },
},
dispatchEvent: () => {},
fetch: (rawURL, options = {}) => {
const url = new URL(rawURL);
requests.push({ url, options });
if (url.pathname === '/api/v1/users/me') {
return Promise.resolve(
apiResponse({ contact_info: '+20 100 200 300' })
);
}
if (url.pathname === '/api/v1/agency-center/overview') {
return Promise.resolve(
apiResponse({
agency: {
agency_id: '9',
short_id: '90009',
name: 'Fami Stars',
created_at_ms: 1_750_000_000_000,
},
})
);
}
if (url.pathname === '/api/v1/wallet/me/balances') {
return Promise.resolve(
apiResponse({
balances: [
{ asset_type: 'POINT', available_amount: 8800 },
],
})
);
}
if (url.pathname === '/api/v1/agency-center/stats') {
return Promise.resolve(
apiResponse({
summary: {
total_earnings: 1200,
gift_income: 700,
share_income: 500,
total_hosts: 1,
gifted_host_count: 1,
},
hosts: [
{
membership_id: '77',
host_user_id: '42',
display_user_id: '100042',
username: 'Fami Host',
diamond_earnings: 700,
valid_mic_days: 3,
},
],
})
);
}
if (url.pathname === '/api/v1/users/by-display-user-id/100099') {
return Promise.resolve(apiResponse({ user_id: '99' }));
}
return Promise.resolve(apiResponse({}));
},
localStorage: {
getItem: (key) => storage.get(key) || null,
removeItem: (key) => storage.delete(key),
setItem: (key, value) => storage.set(key, String(value)),
},
location,
};
window.window = window;
const context = vm.createContext({
Buffer,
CustomEvent: function CustomEvent() {},
Date,
Error,
FormData,
JSON,
Math,
Number,
Object,
Promise,
String,
URL,
URLSearchParams,
console,
document: window.document,
window,
});
return { context, requests, window };
}
function run(context, file) {
vm.runInContext(fs.readFileSync(path.join(root, file), 'utf8'), context, {
filename: file,
});
}
async function main() {
const browser = harness('?token=test-token&env=test');
run(browser.context, 'guild/packages/core/product-context.js');
run(browser.context, 'guild/products/fami/product.js');
run(browser.context, 'common/api.js');
run(browser.context, 'guild/packages/core/date-range.js');
run(browser.context, 'guild/packages/agency-center/agency-center.js');
const range = browser.window.HyGuild.dateRange.create('last_7_days', {
now: new Date(2026, 6, 12),
});
const data = await browser.window.HyGuild.agencyCenter.load(range);
assert.equal(data.agency.shortId, '90009');
assert.equal(data.contact, '+20 100 200 300');
assert.equal(data.pointBalance, 8800);
assert.equal(data.stats.summary.share_income, 500);
assert.equal(data.stats.hosts[0].displayUserId, '100042');
const statsRequest = browser.requests.find(
({ url }) => url.pathname === '/api/v1/agency-center/stats'
);
assert.equal(statsRequest.url.searchParams.get('start_date'), '2026-07-06');
assert.equal(statsRequest.url.searchParams.get('end_date'), '2026-07-12');
await browser.window.HyGuild.agencyCenter.inviteHost('100099');
const invite = browser.requests.find(
({ url }) => url.pathname === '/api/v1/agency-center/invitations/host'
);
assert.ok(invite, 'resolved display ID must be sent to invite endpoint');
assert.deepEqual(JSON.parse(invite.options.body), {
command_id: 'fami-agency-invite-host:fixed-uuid',
target_user_id: '99',
});
browser.requests.forEach(({ options }) => {
assert.equal(options.headers['X-App-Code'], 'fami');
});
const mockBrowser = harness('?mock=1');
run(mockBrowser.context, 'guild/packages/core/product-context.js');
run(mockBrowser.context, 'guild/products/fami/product.js');
run(mockBrowser.context, 'guild/packages/core/date-range.js');
run(mockBrowser.context, 'guild/packages/agency-center/agency-center.js');
const mock = await mockBrowser.window.HyGuild.agencyCenter.load(
mockBrowser.window.HyGuild.dateRange.create('today', {
now: new Date(2026, 6, 12),
})
);
assert.equal(mock.stats.hosts.length, 1);
assert.equal(mockBrowser.requests.length, 0);
console.log('Fami agency center tests passed');
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -0,0 +1,162 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.join(__dirname, '..');
function apiResponse(data) {
return {
headers: { get: () => '' },
ok: true,
status: 200,
statusText: 'OK',
text: () => Promise.resolve(JSON.stringify({ code: 'OK', data })),
};
}
function harness(search = '') {
const requests = [];
const location = {
hash: '',
hostname: 'h5.global-interaction.com',
href: `https://h5.global-interaction.com/guild/fami/bd-center/${search}`,
origin: 'https://h5.global-interaction.com',
pathname: '/guild/fami/bd-center/',
search,
};
const storage = new Map();
const window = {
atob: (value) => Buffer.from(value, 'base64').toString('binary'),
crypto: { randomUUID: () => 'fixed-uuid' },
document: {
currentScript: null,
documentElement: { setAttribute: () => {} },
},
dispatchEvent: () => {},
fetch: (rawURL, options = {}) => {
const url = new URL(rawURL);
requests.push({ url, options });
if (url.pathname === '/api/v1/bd-center/overview')
return Promise.resolve(
apiResponse({
profile: {
user_id: '7',
display_user_id: '100007',
username: 'Fami BD',
contact_info: '+90 555',
},
bd_profile: { user_id: '7' },
})
);
if (url.pathname === '/api/v1/bd-center/stats')
return Promise.resolve(
apiResponse({
items: [
{
agency: {
agency_id: '9',
short_id: '90009',
name: 'Fami Stars',
},
owner: {
display_user_id: '100042',
username: 'Owner',
},
country: 'Türkiye',
total_hosts: 4,
gifted_host_count: 3,
diamond_earnings: 7700,
joined_at_ms: 1750000000000,
},
],
})
);
if (url.pathname === '/api/v1/users/by-display-user-id/100099')
return Promise.resolve(apiResponse({ user_id: '99' }));
return Promise.resolve(apiResponse({}));
},
localStorage: {
getItem: (key) => storage.get(key) || null,
removeItem: (key) => storage.delete(key),
setItem: (key, value) => storage.set(key, String(value)),
},
location,
};
window.window = window;
return {
context: vm.createContext({
Buffer,
CustomEvent: function CustomEvent() {},
Date,
Error,
FormData,
JSON,
Math,
Number,
Object,
Promise,
String,
URL,
URLSearchParams,
console,
document: window.document,
window,
}),
requests,
window,
};
}
function run(context, file) {
vm.runInContext(fs.readFileSync(path.join(root, file), 'utf8'), context, {
filename: file,
});
}
async function main() {
const browser = harness('?token=test-token&env=test');
run(browser.context, 'guild/packages/core/product-context.js');
run(browser.context, 'guild/products/fami/product.js');
run(browser.context, 'common/api.js');
run(browser.context, 'guild/packages/core/date-range.js');
run(browser.context, 'guild/packages/bd-center/bd-center.js');
const range = browser.window.HyGuild.dateRange.create('last_7_days', {
now: new Date(2026, 6, 12),
});
const data = await browser.window.HyGuild.bdCenter.load(range);
assert.equal(data.profile.displayUserId, '100007');
assert.equal(data.stats.agencies[0].diamondEarnings, 7700);
assert.equal(data.stats.agencies[0].country, 'Türkiye');
await browser.window.HyGuild.bdCenter.inviteAgency('100099', 'New Stars');
const invite = browser.requests.find(
({ url }) => url.pathname === '/api/v1/bd/invitations/agency'
);
assert.deepEqual(JSON.parse(invite.options.body), {
command_id: 'fami-bd-invite-agency:fixed-uuid',
target_user_id: '99',
agency_name: 'New Stars',
});
const stats = browser.requests.find(
({ url }) => url.pathname === '/api/v1/bd-center/stats'
);
assert.equal(stats.url.searchParams.get('start_date'), '2026-07-06');
assert.equal(stats.url.searchParams.get('end_date'), '2026-07-12');
browser.requests.forEach(({ options }) =>
assert.equal(options.headers['X-App-Code'], 'fami')
);
const mockBrowser = harness('?mock=1');
run(mockBrowser.context, 'guild/packages/core/product-context.js');
run(mockBrowser.context, 'guild/products/fami/product.js');
run(mockBrowser.context, 'guild/packages/core/date-range.js');
run(mockBrowser.context, 'guild/packages/bd-center/bd-center.js');
const mock = await mockBrowser.window.HyGuild.bdCenter.load(
mockBrowser.window.HyGuild.dateRange.create('today', {
now: new Date(2026, 6, 12),
})
);
assert.equal(mock.stats.agencies.length, 1);
assert.equal(mockBrowser.requests.length, 0);
console.log('Fami BD center tests passed');
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -0,0 +1,170 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.join(__dirname, '..');
function apiResponse(data) {
return {
headers: { get: () => '' },
ok: true,
status: 200,
statusText: 'OK',
text: () => Promise.resolve(JSON.stringify({ code: 'OK', data })),
};
}
function harness(search = '') {
const requests = [];
const location = {
hash: '',
hostname: 'h5.global-interaction.com',
href: `https://h5.global-interaction.com/guild/fami/host-center/${search}`,
origin: 'https://h5.global-interaction.com',
pathname: '/guild/fami/host-center/',
search,
};
const storage = new Map();
const window = {
atob: (value) => Buffer.from(value, 'base64').toString('binary'),
document: {
currentScript: null,
documentElement: { setAttribute: () => {} },
},
dispatchEvent: () => {},
fetch: (rawURL, options = {}) => {
const url = new URL(rawURL);
requests.push({ url, options });
if (url.pathname === '/api/v1/users/me') {
return Promise.resolve(
apiResponse({
user_id: '42',
display_user_id: '100042',
username: 'Fami Host',
contact_info: '+90 555 0100',
})
);
}
if (url.pathname === '/api/v1/host-center/agency') {
return Promise.resolve(
apiResponse({
agency: {
agency_id: '9',
short_id: '90009',
name: 'Fami Agency',
joined_at_ms: 1_750_000_000_000,
},
})
);
}
if (url.pathname === '/api/v1/wallet/me/balances') {
return Promise.resolve(
apiResponse({
balances: [
{ asset_type: 'POINT', available_amount: 8800 },
],
})
);
}
if (url.pathname === '/api/v1/host-center/stats') {
return Promise.resolve(
apiResponse({
metrics: {
diamond_earnings: 700,
diamond_exchanged: 200,
gift_senders: 3,
online_duration_ms: 7200000,
valid_mic_duration_ms: 3600000,
valid_mic_days: 1,
private_message_senders: 4,
new_followers: 5,
},
})
);
}
return Promise.resolve(apiResponse({}));
},
localStorage: {
getItem: (key) => storage.get(key) || null,
removeItem: (key) => storage.delete(key),
setItem: (key, value) => storage.set(key, String(value)),
},
location,
};
window.window = window;
const context = vm.createContext({
Buffer,
CustomEvent: function CustomEvent() {},
Date,
Error,
FormData,
JSON,
Math,
Number,
Object,
Promise,
String,
URL,
URLSearchParams,
console,
document: window.document,
window,
});
return { context, requests, window };
}
function run(context, file) {
vm.runInContext(fs.readFileSync(path.join(root, file), 'utf8'), context, {
filename: file,
});
}
async function main() {
const browser = harness('?token=test-token&env=test');
run(browser.context, 'guild/packages/core/product-context.js');
run(browser.context, 'guild/products/fami/product.js');
run(browser.context, 'common/api.js');
run(browser.context, 'guild/packages/core/date-range.js');
run(browser.context, 'guild/packages/host-center/host-center.js');
const range = browser.window.HyGuild.dateRange.create('last_7_days', {
now: new Date(2026, 6, 12),
});
const data = await browser.window.HyGuild.hostCenter.load(range);
assert.equal(data.profile.displayUserId, '100042');
assert.equal(data.agency.joinedAtMS, 1_750_000_000_000);
assert.equal(data.pointBalance, 8800);
assert.equal(data.stats.metrics.private_message_senders, 4);
assert.deepEqual(Object.keys(data.errors), []);
const statsRequest = browser.requests.find(
({ url }) => url.pathname === '/api/v1/host-center/stats'
);
assert.ok(statsRequest, 'host stats endpoint must be requested');
assert.equal(statsRequest.url.searchParams.get('start_date'), '2026-07-06');
assert.equal(statsRequest.url.searchParams.get('end_date'), '2026-07-12');
browser.requests.forEach(({ options }) => {
assert.equal(options.headers['X-App-Code'], 'fami');
});
const mockBrowser = harness('?mock=1');
run(mockBrowser.context, 'guild/packages/core/product-context.js');
run(mockBrowser.context, 'guild/products/fami/product.js');
run(mockBrowser.context, 'guild/packages/core/date-range.js');
run(mockBrowser.context, 'guild/packages/host-center/host-center.js');
const mock = await mockBrowser.window.HyGuild.hostCenter.load(
mockBrowser.window.HyGuild.dateRange.create('today', {
now: new Date(2026, 6, 12),
})
);
assert.equal(mock.pointBalance, 5152051511212);
assert.equal(mockBrowser.requests.length, 0);
console.log('Fami host center tests passed');
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

153
scripts/test_fami_wallet.js Normal file
View File

@ -0,0 +1,153 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.join(__dirname, '..');
function apiResponse(data) {
return {
headers: { get: () => '' },
ok: true,
status: 200,
statusText: 'OK',
text: () => Promise.resolve(JSON.stringify({ code: 'OK', data })),
};
}
function harness(search = '') {
const requests = [];
const storage = new Map();
const location = {
hash: '',
hostname: 'h5.global-interaction.com',
href: `https://h5.global-interaction.com/guild/fami/wallet/${search}`,
origin: 'https://h5.global-interaction.com',
pathname: '/guild/fami/wallet/',
search,
};
const window = {
atob: (value) => Buffer.from(value, 'base64').toString('binary'),
crypto: { randomUUID: () => 'fixed-uuid' },
document: {
currentScript: null,
documentElement: { setAttribute: () => {} },
},
dispatchEvent: () => {},
fetch: (rawURL, options = {}) => {
const url = new URL(rawURL);
requests.push({ url, options });
if (url.pathname === '/api/v1/point-wallet/overview') {
return Promise.resolve(
apiResponse({
balance: {
available_amount: 2000000,
display_usd: '20.00',
},
points_per_usd: 100000,
fee_bps: 500,
minimum_points: 1000000,
coin_sellers: [
{
user_id: '9007199254740001',
display_user_id: '443344',
nickname: 'Seller',
point_amount: 100000,
seller_coin_amount: 92000,
},
],
})
);
}
if (url.pathname === '/api/v1/wallet/transactions') {
return Promise.resolve(
apiResponse({
transactions: [
{
transaction_id: 'tx-1',
biz_type: 'point_transfer_to_coin_seller',
available_delta: -1000000,
},
],
})
);
}
return Promise.resolve(apiResponse({}));
},
localStorage: {
getItem: (key) => storage.get(key) || null,
removeItem: (key) => storage.delete(key),
setItem: (key, value) => storage.set(key, String(value)),
},
location,
};
window.window = window;
return {
context: vm.createContext({
Buffer,
CustomEvent: function CustomEvent() {},
Date,
Error,
FormData,
JSON,
Math,
Number,
Object,
Promise,
String,
URL,
URLSearchParams,
console,
document: window.document,
window,
}),
requests,
window,
};
}
function run(context, file) {
vm.runInContext(fs.readFileSync(path.join(root, file), 'utf8'), context, {
filename: file,
});
}
async function main() {
const browser = harness('?token=test-token&env=test');
run(browser.context, 'guild/packages/core/product-context.js');
run(browser.context, 'guild/products/fami/product.js');
run(browser.context, 'common/api.js');
run(browser.context, 'guild/packages/wallet/wallet.js');
const data = await browser.window.HyGuild.wallet.load();
assert.equal(data.balance.available, 2000000);
assert.equal(data.sellers[0].userId, '9007199254740001');
assert.equal(data.sellers[0].sellerCoinAmount, 92000);
assert.equal(data.history[0].bizType, 'point_transfer_to_coin_seller');
await browser.window.HyGuild.wallet.transferToCoinSeller(
data.sellers[0].userId,
1000000
);
const transfer = browser.requests.find(
({ url }) =>
url.pathname === '/api/v1/point-wallet/transfer-to-coin-seller'
);
assert.deepEqual(JSON.parse(transfer.options.body), {
command_id: 'fami-point-coin-seller:fixed-uuid',
seller_user_id: '9007199254740001',
point_amount: 1000000,
});
browser.requests.forEach(({ options }) =>
assert.equal(options.headers['X-App-Code'], 'fami')
);
const mockBrowser = harness('?mock=1');
run(mockBrowser.context, 'guild/packages/core/product-context.js');
run(mockBrowser.context, 'guild/products/fami/product.js');
run(mockBrowser.context, 'guild/packages/wallet/wallet.js');
const mock = await mockBrowser.window.HyGuild.wallet.load();
assert.equal(mock.sellers.length, 2);
assert.equal(mockBrowser.requests.length, 0);
console.log('Fami wallet tests passed');
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -0,0 +1,161 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.join(__dirname, '..');
function response(data) {
return {
headers: { get: () => '' },
ok: true,
status: 200,
statusText: 'OK',
text: () => Promise.resolve(JSON.stringify({ code: 'OK', data })),
};
}
function browser(search = '') {
const requests = [];
const attributes = new Map();
const storage = new Map();
const location = {
hash: '',
hostname: 'h5.global-interaction.com',
href: `https://h5.global-interaction.com/guild/fami/host-center/${search}`,
origin: 'https://h5.global-interaction.com',
pathname: '/guild/fami/host-center/',
search,
};
const window = {
atob: (value) => Buffer.from(value, 'base64').toString('binary'),
document: {
documentElement: {
setAttribute: (key, value) => attributes.set(key, value),
},
},
dispatchEvent: () => {},
fetch: (url, options = {}) => {
requests.push({ url, options });
return Promise.resolve(response({ ok: true }));
},
localStorage: {
getItem: (key) => storage.get(key) || null,
removeItem: (key) => storage.delete(key),
setItem: (key, value) => storage.set(key, String(value)),
},
location,
};
window.window = window;
window.URL = URL;
window.URLSearchParams = URLSearchParams;
const context = vm.createContext({
Buffer,
CustomEvent: function CustomEvent() {},
Date,
Error,
FormData,
JSON,
Math,
Number,
Object,
Promise,
String,
URL,
URLSearchParams,
console,
document: window.document,
window,
});
return { attributes, context, requests, window };
}
function runScript(context, relativePath) {
const source = fs.readFileSync(path.join(root, relativePath), 'utf8');
vm.runInContext(source, context, { filename: relativePath });
}
async function requestAppCode({ product, search = '' }) {
const harness = browser(search);
if (product) {
runScript(harness.context, 'guild/packages/core/product-context.js');
runScript(harness.context, `guild/products/${product}/product.js`);
}
runScript(harness.context, 'common/api.js');
await harness.window.HyAppAPI.get('/api/v1/test');
return {
harness,
appCode: harness.requests[0].options.headers['X-App-Code'],
};
}
async function main() {
const legacy = await requestAppCode({ product: '' });
assert.equal(legacy.appCode, 'lalu', 'legacy pages must stay on Lalu');
const fami = await requestAppCode({ product: 'fami' });
assert.equal(fami.appCode, 'fami', 'Fami page default must be isolated');
assert.equal(fami.harness.window.HyAppAPI.getAppCode(), 'fami');
assert.equal(fami.harness.attributes.get('data-hy-product'), 'fami');
const explicit = await requestAppCode({
product: 'fami',
search: '?app_code=lalu&env=test',
});
assert.equal(
explicit.appCode,
'lalu',
'explicit diagnostic tenant must win over the page default'
);
const contextHarness = browser('?token=secret&env=test&language=ar&page=8');
runScript(contextHarness.context, 'guild/packages/core/product-context.js');
runScript(contextHarness.context, 'guild/products/fami/product.js');
const linked = new URL(
contextHarness.window.HyGuild.product.buildURL('../wallet/', {
mode: 'usdt',
})
);
assert.equal(linked.searchParams.get('app_code'), 'fami');
assert.equal(linked.searchParams.get('token'), 'secret');
assert.equal(linked.searchParams.get('env'), 'test');
assert.equal(linked.searchParams.get('language'), 'ar');
assert.equal(linked.searchParams.get('mode'), 'usdt');
assert.equal(linked.searchParams.has('page'), false);
const rangeHarness = browser();
runScript(rangeHarness.context, 'guild/packages/core/date-range.js');
const ranges = rangeHarness.window.HyGuild.dateRange;
assert.equal(
JSON.stringify(
ranges.create('last_7_days', { now: new Date(2026, 6, 12) })
),
JSON.stringify({
preset: 'last_7_days',
startDate: '2026-07-06',
endDate: '2026-07-12',
})
);
assert.equal(
JSON.stringify(
ranges.create('last_month', { now: new Date(2026, 0, 15) })
),
JSON.stringify({
preset: 'last_month',
startDate: '2025-12-01',
endDate: '2025-12-31',
})
);
assert.throws(
() =>
ranges.create('custom', { start: '2026-07-12', end: '2026-07-01' }),
/Invalid custom date range/
);
console.log('guild product architecture tests passed');
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});