Compare commits
2 Commits
0bd9d8d3d8
...
5eabbcf0ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eabbcf0ee | |||
| e87229c1b7 |
66
src/api/admin-integration-sites.ts
Normal file
66
src/api/admin-integration-sites.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { adminRequest } from "@/lib/admin-http";
|
||||
|
||||
import { API_V1_PREFIX } from "./paths";
|
||||
|
||||
import type {
|
||||
AdminIntegrationSiteConnectivityResult,
|
||||
AdminIntegrationSiteCreatePayload,
|
||||
AdminIntegrationSiteDetail,
|
||||
AdminIntegrationSiteListData,
|
||||
AdminIntegrationSiteParameterSheet,
|
||||
AdminIntegrationSiteUpdatePayload,
|
||||
AdminIntegrationSiteWithSecrets,
|
||||
} from "@/types/api/admin-integration-site";
|
||||
|
||||
const A = `${API_V1_PREFIX}/admin`;
|
||||
|
||||
export async function getAdminIntegrationSites(): Promise<AdminIntegrationSiteListData> {
|
||||
return adminRequest.get<AdminIntegrationSiteListData>(`${A}/integration-sites`);
|
||||
}
|
||||
|
||||
export async function getAdminIntegrationSite(id: number): Promise<AdminIntegrationSiteDetail> {
|
||||
return adminRequest.get<AdminIntegrationSiteDetail>(`${A}/integration-sites/${id}`);
|
||||
}
|
||||
|
||||
export async function postAdminIntegrationSite(
|
||||
body: AdminIntegrationSiteCreatePayload,
|
||||
): Promise<AdminIntegrationSiteWithSecrets> {
|
||||
return adminRequest.post<AdminIntegrationSiteWithSecrets>(`${A}/integration-sites`, body);
|
||||
}
|
||||
|
||||
export async function putAdminIntegrationSite(
|
||||
id: number,
|
||||
body: AdminIntegrationSiteUpdatePayload,
|
||||
): Promise<AdminIntegrationSiteDetail> {
|
||||
return adminRequest.put<AdminIntegrationSiteDetail>(`${A}/integration-sites/${id}`, body);
|
||||
}
|
||||
|
||||
export async function postAdminIntegrationSiteRotateSecrets(
|
||||
id: number,
|
||||
): Promise<AdminIntegrationSiteWithSecrets> {
|
||||
return adminRequest.post<AdminIntegrationSiteWithSecrets>(
|
||||
`${A}/integration-sites/${id}/rotate-secrets`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
export async function postAdminIntegrationSiteConnectivityTest(
|
||||
id: number,
|
||||
body: { site_player_id: string; currency_code?: string },
|
||||
): Promise<AdminIntegrationSiteConnectivityResult> {
|
||||
return adminRequest.post<AdminIntegrationSiteConnectivityResult>(
|
||||
`${A}/integration-sites/${id}/connectivity-test`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAdminIntegrationSiteExport(
|
||||
id: number,
|
||||
format: "json" | "csv" = "json",
|
||||
): Promise<AdminIntegrationSiteParameterSheet> {
|
||||
return adminRequest.get<AdminIntegrationSiteParameterSheet>(
|
||||
`${A}/integration-sites/${id}/export`,
|
||||
{ params: { format } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export async function getAdminPlayers(params?: {
|
||||
per_page?: number;
|
||||
keyword?: string;
|
||||
status?: number;
|
||||
site_code?: string;
|
||||
}): Promise<AdminPlayerListData> {
|
||||
return adminRequest.get<AdminPlayerListData>(`${A}/players`, { params });
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export type TicketItemsListQuery = {
|
||||
per_page?: number;
|
||||
player_id?: number;
|
||||
player_account?: string;
|
||||
site_code?: string;
|
||||
draw_no?: string;
|
||||
status?: string[];
|
||||
number?: string;
|
||||
|
||||
14
src/app/admin/(shell)/config/integration-sites/page.tsx
Normal file
14
src/app/admin/(shell)/config/integration-sites/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { IntegrationSitesConsole } from "@/modules/integration/integration-sites-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("config", "integrationSites.title");
|
||||
|
||||
export default function AdminIntegrationSitesPage() {
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
<IntegrationSitesConsole />
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
66
src/hooks/use-admin-site-code-options.ts
Normal file
66
src/hooks/use-admin-site-code-options.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
export type AdminSiteCodeOption = {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 接入站点下拉(已按当前管理员站点权限过滤)。
|
||||
*/
|
||||
export function useAdminSiteCodeOptions(): {
|
||||
sites: AdminSiteCodeOption[];
|
||||
loading: boolean;
|
||||
canChooseSite: boolean;
|
||||
reload: () => Promise<void>;
|
||||
} {
|
||||
const profile = useAdminProfile();
|
||||
const canLoad = adminHasAnyPermission(profile?.permissions, [
|
||||
"prd.integration.view",
|
||||
"prd.integration.manage",
|
||||
]);
|
||||
|
||||
const [sites, setSites] = useState<AdminSiteCodeOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!canLoad) {
|
||||
setSites([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAdminIntegrationSites();
|
||||
setSites(
|
||||
data.items.map((row) => ({
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setSites([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void reload();
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
return {
|
||||
sites,
|
||||
loading,
|
||||
canChooseSite: canLoad && sites.length > 0,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
@@ -29,7 +29,77 @@
|
||||
"jackpotTitle": "Jackpot",
|
||||
"jackpotDesc": "Pool parameters and ledger records",
|
||||
"riskCapTitle": "Risk cap rules",
|
||||
"riskCapDesc": "Per-number payout caps and occupancy"
|
||||
"riskCapDesc": "Per-number payout caps and occupancy",
|
||||
"integrationTitle": "Integration sites",
|
||||
"integrationDesc": "site_code, JWT secrets, partner wallet URL, iframe allowlist"
|
||||
},
|
||||
"integrationSites": {
|
||||
"title": "Integration sites",
|
||||
"description": "Maintain partner integration settings in admin. site_code cannot be changed after creation.",
|
||||
"create": "New site",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"loading": "Loading…",
|
||||
"empty": "No integration sites",
|
||||
"loadFailed": "Failed to load integration sites",
|
||||
"saveFailed": "Save failed",
|
||||
"createSuccess": "Created site {{code}}",
|
||||
"updateSuccess": "Updated site {{code}}",
|
||||
"connectivityTest": "Test connectivity",
|
||||
"connectivityTitle": "Partner wallet connectivity",
|
||||
"connectivityDescription": "Call the balance API for site {{code}} using a test player.",
|
||||
"connectivityPlayerId": "Test site_player_id",
|
||||
"connectivityRun": "Run test",
|
||||
"connectivityRunning": "Testing…",
|
||||
"connectivitySuccess": "Connectivity OK",
|
||||
"connectivityFailed": "Connectivity failed",
|
||||
"exportParams": "Export params",
|
||||
"exportSuccess": "Exported parameter sheet for {{code}}",
|
||||
"exportFailed": "Export failed",
|
||||
"rotateSecrets": "Rotate secrets",
|
||||
"rotateSuccess": "Rotated secrets for {{code}}",
|
||||
"rotateFailed": "Failed to rotate secrets",
|
||||
"rotateConfirmTitle": "Rotate secrets?",
|
||||
"rotateConfirmDescription": "New SSO and wallet keys will be generated for {{code}}. Old keys stop working immediately.",
|
||||
"rotateConfirm": "Rotate",
|
||||
"secretsTitle": "Save these secrets now",
|
||||
"secretsDescription": "Secrets for {{code}} are shown only once.",
|
||||
"secretsDismiss": "I have saved them",
|
||||
"copied": "Copied {{field}}",
|
||||
"copyFailed": "Copy failed",
|
||||
"noPermission": "No permission to view integration sites",
|
||||
"codeImmutable": "site_code cannot be changed after creation",
|
||||
"statusEnabled": "Enabled",
|
||||
"statusDisabled": "Disabled",
|
||||
"dialogCreateTitle": "New integration site",
|
||||
"dialogEditTitle": "Edit integration site",
|
||||
"dialogDescription": "Default wallet paths are fine unless the partner uses custom URLs.",
|
||||
"form": {
|
||||
"required": "Site name is required",
|
||||
"codeRequired": "site_code is required"
|
||||
},
|
||||
"columns": {
|
||||
"code": "site_code",
|
||||
"name": "Name",
|
||||
"status": "Status",
|
||||
"walletUrl": "Wallet API",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"fields": {
|
||||
"code": "site_code",
|
||||
"name": "Site name",
|
||||
"currency": "Default currency",
|
||||
"status": "Status",
|
||||
"walletApiUrl": "Partner wallet base URL",
|
||||
"lotteryH5BaseUrl": "Lottery H5 base URL (optional)",
|
||||
"iframeOrigins": "iframe allowlist (one origin per line)",
|
||||
"notes": "Notes",
|
||||
"ssoSecret": "SSO secret",
|
||||
"walletApiKey": "Wallet API key"
|
||||
}
|
||||
},
|
||||
"versionStatus": {
|
||||
"active": "Active",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"listTitle": "Player list",
|
||||
"createPlayer": "Create player",
|
||||
"searchPlaceholder": "Search by player ID / username / nickname",
|
||||
"filterSite": "Site",
|
||||
"filterAllSites": "All sites",
|
||||
"search": "Search",
|
||||
"refresh": "Refresh",
|
||||
"loadFailed": "Failed to load player list",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"title": "Ticket list",
|
||||
"playerTicketQuery": "Ticket query",
|
||||
"filterSite": "Site",
|
||||
"filterAllSites": "All sites",
|
||||
"playerId": "Player ID / account",
|
||||
"invalidPlayerId": "Enter a valid player ID or account",
|
||||
"playerIdPlaceholder": "Leave blank for all tickets; enter player ID or account",
|
||||
@@ -33,12 +35,14 @@
|
||||
"all": "All",
|
||||
"pending_confirm": "Pending confirmation",
|
||||
"partial_pending_confirm": "Partially pending confirmation",
|
||||
"success": "Bet placed",
|
||||
"placed": "Placed",
|
||||
"pending_draw": "Awaiting draw",
|
||||
"partial_failed": "Partially failed",
|
||||
"failed": "Bet failed",
|
||||
"pending_payout": "Pending payout",
|
||||
"settled_win": "Settled win",
|
||||
"settled_lose": "Settled loss"
|
||||
"settled_lose": "Settled loss",
|
||||
"refunded": "Refunded"
|
||||
},
|
||||
"allTickets": "All tickets"
|
||||
}
|
||||
|
||||
@@ -32,12 +32,14 @@
|
||||
"all": "सबै",
|
||||
"pending_confirm": "पुष्टि बाँकी",
|
||||
"partial_pending_confirm": "आंशिक पुष्टि बाँकी",
|
||||
"success": "बेट सफल",
|
||||
"placed": "बेट राखियो",
|
||||
"pending_draw": "ड्र पर्खँदै",
|
||||
"partial_failed": "आंशिक असफल",
|
||||
"failed": "बेट असफल",
|
||||
"pending_payout": "भुक्तानी बाँकी",
|
||||
"settled_win": "जित सेटल भयो",
|
||||
"settled_lose": "हार सेटल भयो"
|
||||
"settled_lose": "हार सेटल भयो",
|
||||
"refunded": "फिर्ता भयो"
|
||||
},
|
||||
"allTickets": "सबै टिकट"
|
||||
}
|
||||
|
||||
@@ -29,7 +29,77 @@
|
||||
"jackpotTitle": "奖池",
|
||||
"jackpotDesc": "奖池参数与进账流水",
|
||||
"riskCapTitle": "限额版本",
|
||||
"riskCapDesc": "号码赔付封顶与占用视图"
|
||||
"riskCapDesc": "号码赔付封顶与占用视图",
|
||||
"integrationTitle": "主站接入站点",
|
||||
"integrationDesc": "site_code、JWT 密钥、主站钱包 URL 与 iframe 白名单"
|
||||
},
|
||||
"integrationSites": {
|
||||
"title": "主站接入站点",
|
||||
"description": "由运营在后台维护各主站对接参数,并通过权限控制谁能查看或修改。site_code 创建后不可修改。",
|
||||
"create": "新建站点",
|
||||
"edit": "编辑",
|
||||
"save": "保存",
|
||||
"saving": "保存中…",
|
||||
"cancel": "取消",
|
||||
"copy": "复制",
|
||||
"loading": "加载中…",
|
||||
"empty": "暂无接入站点",
|
||||
"loadFailed": "加载接入站点失败",
|
||||
"saveFailed": "保存失败",
|
||||
"createSuccess": "已创建站点 {{code}}",
|
||||
"updateSuccess": "已更新站点 {{code}}",
|
||||
"connectivityTest": "联通检测",
|
||||
"connectivityTitle": "主站钱包联通检测",
|
||||
"connectivityDescription": "使用测试玩家调用站点 {{code}} 的 balance 接口。",
|
||||
"connectivityPlayerId": "测试 site_player_id",
|
||||
"connectivityRun": "开始检测",
|
||||
"connectivityRunning": "检测中…",
|
||||
"connectivitySuccess": "联通成功",
|
||||
"connectivityFailed": "联通失败",
|
||||
"exportParams": "导出参数表",
|
||||
"exportSuccess": "已导出 {{code}} 参数表",
|
||||
"exportFailed": "导出失败",
|
||||
"rotateSecrets": "重置密钥",
|
||||
"rotateSuccess": "已重置站点 {{code}} 的密钥",
|
||||
"rotateFailed": "重置密钥失败",
|
||||
"rotateConfirmTitle": "确认重置密钥?",
|
||||
"rotateConfirmDescription": "将重新生成站点 {{code}} 的 SSO 与钱包密钥,旧密钥立即失效。",
|
||||
"rotateConfirm": "确认重置",
|
||||
"secretsTitle": "请妥善保存密钥",
|
||||
"secretsDescription": "站点 {{code}} 的密钥仅显示一次,关闭后无法再次查看完整内容。",
|
||||
"secretsDismiss": "我已保存",
|
||||
"copied": "已复制 {{field}}",
|
||||
"copyFailed": "复制失败",
|
||||
"noPermission": "当前账号无接入站点查看权限",
|
||||
"codeImmutable": "site_code 创建后不可修改",
|
||||
"statusEnabled": "启用",
|
||||
"statusDisabled": "停用",
|
||||
"dialogCreateTitle": "新建接入站点",
|
||||
"dialogEditTitle": "编辑接入站点",
|
||||
"dialogDescription": "钱包路径使用默认值即可,除非主站 URL 规范不同。",
|
||||
"form": {
|
||||
"required": "请填写站点名称",
|
||||
"codeRequired": "请填写 site_code"
|
||||
},
|
||||
"columns": {
|
||||
"code": "site_code",
|
||||
"name": "名称",
|
||||
"status": "状态",
|
||||
"walletUrl": "钱包 API",
|
||||
"actions": "操作"
|
||||
},
|
||||
"fields": {
|
||||
"code": "site_code",
|
||||
"name": "站点名称",
|
||||
"currency": "默认币种",
|
||||
"status": "状态",
|
||||
"walletApiUrl": "主站钱包根 URL",
|
||||
"lotteryH5BaseUrl": "彩票 H5 基址(可选)",
|
||||
"iframeOrigins": "iframe 白名单(每行一个 origin)",
|
||||
"notes": "备注",
|
||||
"ssoSecret": "SSO 密钥",
|
||||
"walletApiKey": "钱包 API 密钥"
|
||||
}
|
||||
},
|
||||
"versionStatus": {
|
||||
"active": "生效中",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"listTitle": "玩家列表",
|
||||
"createPlayer": "新建玩家",
|
||||
"searchPlaceholder": "按玩家 ID / 用户名 / 昵称搜索",
|
||||
"filterSite": "主站站点",
|
||||
"filterAllSites": "全部站点",
|
||||
"search": "搜索",
|
||||
"refresh": "刷新",
|
||||
"loadFailed": "加载玩家列表失败",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"title": "注单列表",
|
||||
"playerTicketQuery": "注单查询",
|
||||
"filterSite": "主站站点",
|
||||
"filterAllSites": "全部站点",
|
||||
"playerId": "玩家 ID / 账号",
|
||||
"invalidPlayerId": "请输入有效玩家 ID 或账号",
|
||||
"playerIdPlaceholder": "留空显示全部,可输入玩家 ID 或账号",
|
||||
@@ -33,12 +35,14 @@
|
||||
"all": "全部",
|
||||
"pending_confirm": "待确认",
|
||||
"partial_pending_confirm": "部分待确认",
|
||||
"success": "已投注成功",
|
||||
"placed": "已下单",
|
||||
"pending_draw": "待开奖",
|
||||
"partial_failed": "部分失败",
|
||||
"failed": "投注失败",
|
||||
"pending_payout": "待派奖",
|
||||
"settled_win": "已中奖结算",
|
||||
"settled_lose": "已未中奖结算"
|
||||
"settled_lose": "已未中奖结算",
|
||||
"refunded": "已退款"
|
||||
},
|
||||
"allTickets": "全部注单"
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const EXACT_ROUTES: Record<string, PageTitleSpec> = {
|
||||
"/admin/settings/currencies": { ns: "config", key: "currencies.title" },
|
||||
"/admin/currencies": { ns: "config", key: "currencies.title" },
|
||||
"/admin/config": { ns: "config", key: "hub.title" },
|
||||
"/admin/config/integration-sites": { ns: "config", key: "integrationSites.title" },
|
||||
"/admin/rules/plays": { ns: "config", key: "nav.rulesPlaysTitle" },
|
||||
"/admin/rules/odds": { ns: "config", key: "nav.rulesOddsTitle" },
|
||||
"/admin/jackpot": { ns: "jackpot", key: "configTitle" },
|
||||
|
||||
@@ -41,6 +41,12 @@ const HUB_CARDS: HubCard[] = [
|
||||
descKey: "hub.riskCapDesc",
|
||||
requiredAny: ["prd.risk_cap.manage", "prd.risk_cap.view"],
|
||||
},
|
||||
{
|
||||
href: "/admin/config/integration-sites",
|
||||
titleKey: "hub.integrationTitle",
|
||||
descKey: "hub.integrationDesc",
|
||||
requiredAny: ["prd.integration.view", "prd.integration.manage"],
|
||||
},
|
||||
];
|
||||
|
||||
export function ConfigHubScreen() {
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
@@ -156,12 +155,6 @@ export function PlayConfigDocScreen() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const detailRequestSeq = useRef(0);
|
||||
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||
const [rulePlayCode, setRulePlayCode] = useState<string | null>(null);
|
||||
const [ruleDraftZh, setRuleDraftZh] = useState("");
|
||||
const [ruleDraftEn, setRuleDraftEn] = useState("");
|
||||
const [ruleDraftNe, setRuleDraftNe] = useState("");
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
setLoadingList(true);
|
||||
setError(null);
|
||||
@@ -361,29 +354,6 @@ export function PlayConfigDocScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
function openRuleEditor(play_code: string) {
|
||||
const item = draftRows.find((row) => row.play_code === play_code);
|
||||
setRulePlayCode(play_code);
|
||||
setRuleDraftZh(item?.rule_text_zh ?? "");
|
||||
setRuleDraftEn(item?.rule_text_en ?? "");
|
||||
setRuleDraftNe(item?.rule_text_ne ?? "");
|
||||
setRuleDialogOpen(true);
|
||||
}
|
||||
|
||||
function saveRuleDraft() {
|
||||
if (!rulePlayCode) {
|
||||
return;
|
||||
}
|
||||
updateConfigRow(rulePlayCode, {
|
||||
rule_text_zh: ruleDraftZh.trim() || null,
|
||||
rule_text_en: ruleDraftEn.trim() || null,
|
||||
rule_text_ne: ruleDraftNe.trim() || null,
|
||||
});
|
||||
setRuleDialogOpen(false);
|
||||
setRulePlayCode(null);
|
||||
toast.message(t("play.ruleSavedLocal", { ns: "config" }));
|
||||
}
|
||||
|
||||
function renderDisplayNameReadonly(row: PlayConfigItemRow) {
|
||||
const name = row.display_name?.trim();
|
||||
return <span>{name || row.play_code}</span>;
|
||||
@@ -580,7 +550,6 @@ export function PlayConfigDocScreen() {
|
||||
<TableHead className="w-24 text-center">{t("play.table.order", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-center">{t("play.table.minBet", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-center">{t("play.table.maxBet", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[140px] text-center">{t("play.table.actions", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -712,78 +681,12 @@ export function PlayConfigDocScreen() {
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isDraft ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => openRuleEditor(row.play_code)}
|
||||
>
|
||||
{t("play.actions.ruleText", { ns: "config" })}
|
||||
</Button>
|
||||
) : (
|
||||
<AdminStatusBadge status="disabled" className="mx-auto w-fit">
|
||||
{t("play.states.readOnly", { ns: "config" })}
|
||||
</AdminStatusBadge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("play.ruleDialog.title", { ns: "config" })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("play.ruleDialog.description", { ns: "config", playCode: rulePlayCode ?? "—" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="zh" className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
|
||||
<TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
|
||||
<TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="zh" className="mt-3">
|
||||
<textarea
|
||||
id="rule-zh"
|
||||
className="border-input bg-background ring-ring/24 focus-visible:ring-[3px] min-h-[140px] w-full rounded-lg border px-3 py-2 text-sm outline-none"
|
||||
value={ruleDraftZh}
|
||||
onChange={(e) => setRuleDraftZh(e.target.value)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="en" className="mt-3">
|
||||
<textarea
|
||||
id="rule-en"
|
||||
className="border-input bg-background ring-ring/24 focus-visible:ring-[3px] min-h-[140px] w-full rounded-lg border px-3 py-2 text-sm outline-none"
|
||||
value={ruleDraftEn}
|
||||
onChange={(e) => setRuleDraftEn(e.target.value)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="ne" className="mt-3">
|
||||
<textarea
|
||||
id="rule-ne"
|
||||
className="border-input bg-background ring-ring/24 focus-visible:ring-[3px] min-h-[140px] w-full rounded-lg border px-3 py-2 text-sm outline-none"
|
||||
value={ruleDraftNe}
|
||||
onChange={(e) => setRuleDraftNe(e.target.value)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRuleDialogOpen(false)}>
|
||||
{t("actions.cancel", { ns: "adminUsers" })}
|
||||
</Button>
|
||||
<Button type="button" onClick={saveRuleDraft}>
|
||||
{t("play.ruleDialog.apply", { ns: "config" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
|
||||
677
src/modules/integration/integration-sites-console.tsx
Normal file
677
src/modules/integration/integration-sites-console.tsx
Normal file
@@ -0,0 +1,677 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminIntegrationSite,
|
||||
getAdminIntegrationSiteExport,
|
||||
getAdminIntegrationSites,
|
||||
postAdminIntegrationSite,
|
||||
postAdminIntegrationSiteConnectivityTest,
|
||||
postAdminIntegrationSiteRotateSecrets,
|
||||
putAdminIntegrationSite,
|
||||
} from "@/api/admin-integration-sites";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminIntegrationSiteConnectivityResult,
|
||||
AdminIntegrationSiteRow,
|
||||
AdminIntegrationSiteSecrets,
|
||||
AdminIntegrationSiteWithSecrets,
|
||||
} from "@/types/api/admin-integration-site";
|
||||
|
||||
type FormState = {
|
||||
code: string;
|
||||
name: string;
|
||||
currency_code: string;
|
||||
status: number;
|
||||
wallet_api_url: string;
|
||||
wallet_debit_path: string;
|
||||
wallet_credit_path: string;
|
||||
wallet_balance_path: string;
|
||||
wallet_timeout_seconds: string;
|
||||
iframe_allowed_origins: string;
|
||||
lottery_h5_base_url: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
code: "",
|
||||
name: "",
|
||||
currency_code: "NPR",
|
||||
status: 1,
|
||||
wallet_api_url: "",
|
||||
wallet_debit_path: "/wallet/debit-for-lottery",
|
||||
wallet_credit_path: "/wallet/credit-from-lottery",
|
||||
wallet_balance_path: "/wallet/balance",
|
||||
wallet_timeout_seconds: "10",
|
||||
iframe_allowed_origins: "",
|
||||
lottery_h5_base_url: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
function originsToText(origins: string[] | undefined): string {
|
||||
return (origins ?? []).join("\n");
|
||||
}
|
||||
|
||||
function textToOrigins(text: string): string[] {
|
||||
return text
|
||||
.split(/[\n,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function rowToForm(row: AdminIntegrationSiteRow & Partial<FormState>): FormState {
|
||||
return {
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
currency_code: row.currency_code,
|
||||
status: row.status,
|
||||
wallet_api_url: row.wallet_api_url ?? "",
|
||||
wallet_debit_path: row.wallet_debit_path ?? "/wallet/debit-for-lottery",
|
||||
wallet_credit_path: row.wallet_credit_path ?? "/wallet/credit-from-lottery",
|
||||
wallet_balance_path: row.wallet_balance_path ?? "/wallet/balance",
|
||||
wallet_timeout_seconds: String(row.wallet_timeout_seconds ?? 10),
|
||||
iframe_allowed_origins: originsToText(row.iframe_allowed_origins as string[] | undefined),
|
||||
lottery_h5_base_url: row.lottery_h5_base_url ?? "",
|
||||
notes: row.notes ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function formToPayload(form: FormState, includeCode: boolean) {
|
||||
const base = {
|
||||
name: form.name.trim(),
|
||||
currency_code: form.currency_code.trim() || "NPR",
|
||||
status: form.status,
|
||||
wallet_api_url: form.wallet_api_url.trim() || null,
|
||||
wallet_debit_path: form.wallet_debit_path.trim(),
|
||||
wallet_credit_path: form.wallet_credit_path.trim(),
|
||||
wallet_balance_path: form.wallet_balance_path.trim(),
|
||||
wallet_timeout_seconds: Number.parseInt(form.wallet_timeout_seconds, 10) || 10,
|
||||
iframe_allowed_origins: textToOrigins(form.iframe_allowed_origins),
|
||||
lottery_h5_base_url: form.lottery_h5_base_url.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
};
|
||||
|
||||
if (includeCode) {
|
||||
return { code: form.code.trim(), ...base };
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
export function IntegrationSitesConsole() {
|
||||
const { t } = useTranslation("config");
|
||||
const profile = useAdminProfile();
|
||||
const canView = adminHasAnyPermission(profile?.permissions, [
|
||||
"prd.integration.view",
|
||||
"prd.integration.manage",
|
||||
]);
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.integration.manage"]);
|
||||
|
||||
const [items, setItems] = useState<AdminIntegrationSiteRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [mode, setMode] = useState<"create" | "edit">("create");
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [rotateTarget, setRotateTarget] = useState<AdminIntegrationSiteRow | null>(null);
|
||||
const [rotateBusy, setRotateBusy] = useState(false);
|
||||
const [secretsDialog, setSecretsDialog] = useState<{
|
||||
siteCode: string;
|
||||
secrets: AdminIntegrationSiteSecrets;
|
||||
} | null>(null);
|
||||
const [connectivityTarget, setConnectivityTarget] = useState<AdminIntegrationSiteRow | null>(
|
||||
null,
|
||||
);
|
||||
const [connectivityPlayerId, setConnectivityPlayerId] = useState("10001");
|
||||
const [connectivityCurrency, setConnectivityCurrency] = useState("NPR");
|
||||
const [connectivityBusy, setConnectivityBusy] = useState(false);
|
||||
const [connectivityResult, setConnectivityResult] =
|
||||
useState<AdminIntegrationSiteConnectivityResult | null>(null);
|
||||
const [exportBusyId, setExportBusyId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!canView) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAdminIntegrationSites();
|
||||
setItems(data.items);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canView, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
function openCreate(): void {
|
||||
setMode("create");
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
async function openEdit(row: AdminIntegrationSiteRow): Promise<void> {
|
||||
setMode("edit");
|
||||
setEditingId(row.id);
|
||||
setDialogOpen(true);
|
||||
try {
|
||||
const detail = await getAdminIntegrationSite(row.id);
|
||||
setForm(rowToForm(detail));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.loadFailed"),
|
||||
);
|
||||
setDialogOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateForm<K extends keyof FormState>(key: K, value: FormState[K]): void {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function showSecretsOnce(result: AdminIntegrationSiteWithSecrets): void {
|
||||
if (result.secrets) {
|
||||
setSecretsDialog({ siteCode: result.code, secrets: result.secrets });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!canManage) return;
|
||||
|
||||
if (form.name.trim() === "") {
|
||||
toast.error(t("integrationSites.form.required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "create" && form.code.trim() === "") {
|
||||
toast.error(t("integrationSites.form.codeRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const created = await postAdminIntegrationSite(formToPayload(form, true));
|
||||
toast.success(t("integrationSites.createSuccess", { code: created.code }));
|
||||
showSecretsOnce(created);
|
||||
} else if (editingId !== null) {
|
||||
await putAdminIntegrationSite(editingId, formToPayload(form, false));
|
||||
toast.success(t("integrationSites.updateSuccess", { code: form.code }));
|
||||
}
|
||||
setDialogOpen(false);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.saveFailed"),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRotate(): Promise<void> {
|
||||
if (!rotateTarget || !canManage) return;
|
||||
|
||||
setRotateBusy(true);
|
||||
try {
|
||||
const result = await postAdminIntegrationSiteRotateSecrets(rotateTarget.id);
|
||||
toast.success(t("integrationSites.rotateSuccess", { code: rotateTarget.code }));
|
||||
setRotateTarget(null);
|
||||
showSecretsOnce(result);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.rotateFailed"),
|
||||
);
|
||||
} finally {
|
||||
setRotateBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openConnectivity(row: AdminIntegrationSiteRow): void {
|
||||
setConnectivityTarget(row);
|
||||
setConnectivityPlayerId("10001");
|
||||
setConnectivityCurrency(row.currency_code || "NPR");
|
||||
setConnectivityResult(null);
|
||||
}
|
||||
|
||||
async function runConnectivityTest(): Promise<void> {
|
||||
if (!connectivityTarget) return;
|
||||
|
||||
setConnectivityBusy(true);
|
||||
setConnectivityResult(null);
|
||||
try {
|
||||
const result = await postAdminIntegrationSiteConnectivityTest(connectivityTarget.id, {
|
||||
site_player_id: connectivityPlayerId.trim(),
|
||||
currency_code: connectivityCurrency.trim() || undefined,
|
||||
});
|
||||
setConnectivityResult(result);
|
||||
if (result.probe.success) {
|
||||
toast.success(t("integrationSites.connectivitySuccess"));
|
||||
} else {
|
||||
toast.error(result.probe.message ?? t("integrationSites.connectivityFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError
|
||||
? error.message
|
||||
: t("integrationSites.connectivityFailed"),
|
||||
);
|
||||
} finally {
|
||||
setConnectivityBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportParameterSheet(row: AdminIntegrationSiteRow): Promise<void> {
|
||||
setExportBusyId(row.id);
|
||||
try {
|
||||
const sheet = await getAdminIntegrationSiteExport(row.id);
|
||||
const blob = new Blob([JSON.stringify(sheet, null, 2)], {
|
||||
type: "application/json;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `integration-${row.code}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("integrationSites.exportSuccess", { code: row.code }));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.exportFailed"),
|
||||
);
|
||||
} finally {
|
||||
setExportBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(label: string, value: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(t("integrationSites.copied", { field: label }));
|
||||
} catch {
|
||||
toast.error(t("integrationSites.copyFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<AdminPageCard title={t("integrationSites.title")}>
|
||||
<p className="text-sm text-muted-foreground">{t("integrationSites.noPermission")}</p>
|
||||
</AdminPageCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard
|
||||
title={t("integrationSites.title")}
|
||||
description={t("integrationSites.description")}
|
||||
actions={
|
||||
canManage ? (
|
||||
<Button type="button" onClick={openCreate}>
|
||||
{t("integrationSites.create")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">{t("integrationSites.loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("integrationSites.empty")}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("integrationSites.columns.code")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.name")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.status")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.walletUrl")}</TableHead>
|
||||
<TableHead className="text-right">{t("integrationSites.columns.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.code}</TableCell>
|
||||
<TableCell>{row.name}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge
|
||||
tone={row.status === 1 ? "success" : "muted"}
|
||||
label={
|
||||
row.status === 1
|
||||
? t("integrationSites.statusEnabled")
|
||||
: t("integrationSites.statusDisabled")
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[240px] truncate text-xs text-muted-foreground">
|
||||
{row.wallet_api_url ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openConnectivity(row)}
|
||||
>
|
||||
{t("integrationSites.connectivityTest")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exportBusyId === row.id}
|
||||
onClick={() => void exportParameterSheet(row)}
|
||||
>
|
||||
{t("integrationSites.exportParams")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void openEdit(row)}>
|
||||
{t("integrationSites.edit")}
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setRotateTarget(row)}
|
||||
>
|
||||
{t("integrationSites.rotateSecrets")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</AdminPageCard>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "create"
|
||||
? t("integrationSites.dialogCreateTitle")
|
||||
: t("integrationSites.dialogEditTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("integrationSites.dialogDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-code">{t("integrationSites.fields.code")}</Label>
|
||||
<Input
|
||||
id="is-code"
|
||||
value={form.code}
|
||||
disabled={mode === "edit"}
|
||||
onChange={(e) => updateForm("code", e.target.value)}
|
||||
placeholder="partner-a"
|
||||
/>
|
||||
{mode === "edit" ? (
|
||||
<p className="text-xs text-muted-foreground">{t("integrationSites.codeImmutable")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-name">{t("integrationSites.fields.name")}</Label>
|
||||
<Input
|
||||
id="is-name"
|
||||
value={form.name}
|
||||
onChange={(e) => updateForm("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-currency">{t("integrationSites.fields.currency")}</Label>
|
||||
<Input
|
||||
id="is-currency"
|
||||
value={form.currency_code}
|
||||
onChange={(e) => updateForm("currency_code", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-status">{t("integrationSites.fields.status")}</Label>
|
||||
<select
|
||||
id="is-status"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
|
||||
value={form.status}
|
||||
onChange={(e) => updateForm("status", Number(e.target.value))}
|
||||
>
|
||||
<option value={1}>{t("integrationSites.statusEnabled")}</option>
|
||||
<option value={0}>{t("integrationSites.statusDisabled")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-wallet-url">{t("integrationSites.fields.walletApiUrl")}</Label>
|
||||
<Input
|
||||
id="is-wallet-url"
|
||||
value={form.wallet_api_url}
|
||||
onChange={(e) => updateForm("wallet_api_url", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-h5">{t("integrationSites.fields.lotteryH5BaseUrl")}</Label>
|
||||
<Input
|
||||
id="is-h5"
|
||||
value={form.lottery_h5_base_url}
|
||||
onChange={(e) => updateForm("lottery_h5_base_url", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-origins">{t("integrationSites.fields.iframeOrigins")}</Label>
|
||||
<Textarea
|
||||
id="is-origins"
|
||||
rows={3}
|
||||
value={form.iframe_allowed_origins}
|
||||
onChange={(e) => updateForm("iframe_allowed_origins", e.target.value)}
|
||||
placeholder="https://www.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-notes">{t("integrationSites.fields.notes")}</Label>
|
||||
<Textarea
|
||||
id="is-notes"
|
||||
rows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => updateForm("notes", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
{t("integrationSites.cancel")}
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<Button type="button" disabled={saving} onClick={() => void handleSubmit()}>
|
||||
{saving ? t("integrationSites.saving") : t("integrationSites.save")}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={rotateTarget !== null} onOpenChange={(open) => !open && setRotateTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("integrationSites.rotateConfirmTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("integrationSites.rotateConfirmDescription", {
|
||||
code: rotateTarget?.code ?? "",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRotateTarget(null)}>
|
||||
{t("integrationSites.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={rotateBusy}
|
||||
onClick={() => void confirmRotate()}
|
||||
>
|
||||
{rotateBusy ? t("integrationSites.saving") : t("integrationSites.rotateConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={connectivityTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConnectivityTarget(null);
|
||||
setConnectivityResult(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("integrationSites.connectivityTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("integrationSites.connectivityDescription", {
|
||||
code: connectivityTarget?.code ?? "",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ct-player">{t("integrationSites.connectivityPlayerId")}</Label>
|
||||
<Input
|
||||
id="ct-player"
|
||||
value={connectivityPlayerId}
|
||||
onChange={(e) => setConnectivityPlayerId(e.target.value)}
|
||||
placeholder="10001"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ct-currency">{t("integrationSites.fields.currency")}</Label>
|
||||
<Input
|
||||
id="ct-currency"
|
||||
value={connectivityCurrency}
|
||||
onChange={(e) => setConnectivityCurrency(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{connectivityResult ? (
|
||||
<div className="rounded-md border bg-muted/40 p-3 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(connectivityResult.probe, null, 2)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setConnectivityTarget(null)}>
|
||||
{t("integrationSites.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={connectivityBusy || connectivityPlayerId.trim() === ""}
|
||||
onClick={() => void runConnectivityTest()}
|
||||
>
|
||||
{connectivityBusy
|
||||
? t("integrationSites.connectivityRunning")
|
||||
: t("integrationSites.connectivityRun")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={secretsDialog !== null} onOpenChange={(open) => !open && setSecretsDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("integrationSites.secretsTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("integrationSites.secretsDescription", { code: secretsDialog?.siteCode ?? "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{secretsDialog ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("integrationSites.fields.ssoSecret")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={secretsDialog.secrets.sso_jwt_secret} className="font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void copyText(
|
||||
t("integrationSites.fields.ssoSecret"),
|
||||
secretsDialog.secrets.sso_jwt_secret,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("integrationSites.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("integrationSites.fields.walletApiKey")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={secretsDialog.secrets.wallet_api_key} className="font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void copyText(
|
||||
t("integrationSites.fields.walletApiKey"),
|
||||
secretsDialog.secrets.wallet_api_key,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("integrationSites.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={() => setSecretsDialog(null)}>
|
||||
{t("integrationSites.secretsDismiss")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -237,209 +237,270 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
const d = drafts[p.id] ?? toDraft(p);
|
||||
const adj = adjustmentDrafts[p.id] ?? { direction: "increase", amount: "", reason: "" };
|
||||
const ledger = adjustmentRows[p.id] ?? [];
|
||||
const currentAmount = formatAdminMinorDecimal(p.current_amount, p.currency_code);
|
||||
const triggerThreshold = formatAdminMinorDecimal(p.trigger_threshold, p.currency_code);
|
||||
const minBetAmount = formatAdminMinorDecimal(p.min_bet_amount, p.currency_code);
|
||||
const statusOn = d.status === "1";
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="space-y-4 rounded-xl border border-border/60 bg-muted/10 p-4"
|
||||
className="space-y-3 rounded-xl border border-border/60 bg-background p-3 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
|
||||
<p className="text-muted-foreground font-mono text-xs">
|
||||
{t("displayBalance", {
|
||||
amount: formatAdminMinorDecimal(p.current_amount, p.currency_code),
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
|
||||
<p className="text-muted-foreground text-xs">{t("configTitle")}</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground font-mono text-xs">{t("displayBalance", { amount: currentAmount })}</p>
|
||||
</div>
|
||||
{canManageJackpot ? (
|
||||
<div className="space-y-3 rounded-lg border border-border/60 bg-background/60 p-4">
|
||||
<p className="text-sm font-medium">{t("balanceAdjustmentTitle")}</p>
|
||||
<p className="text-muted-foreground text-xs">{t("balanceAdjustmentHint")}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("adjustmentDirection")}</Label>
|
||||
<Select
|
||||
value={adj.direction}
|
||||
onValueChange={(value: "increase" | "decrease") =>
|
||||
updateAdjustmentDraft(p.id, { direction: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="font-mono">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="increase">{t("adjustmentIncrease")}</SelectItem>
|
||||
<SelectItem value="decrease">{t("adjustmentDecrease")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||
<p className="text-muted-foreground text-xs">{t("currentAmount")}</p>
|
||||
<p className="mt-0.5 font-mono text-base font-semibold">{currentAmount}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||
<p className="text-muted-foreground text-xs">{t("status")}</p>
|
||||
<p className="mt-0.5 text-base font-semibold">
|
||||
{statusOn ? t("enabled") : t("disabled")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||
<p className="text-muted-foreground text-xs">{t("payoutRate")}</p>
|
||||
<p className="mt-0.5 font-mono text-base font-semibold">{d.payout_rate}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||
<p className="text-muted-foreground text-xs">{t("forceTriggerGap")}</p>
|
||||
<p className="mt-0.5 font-mono text-base font-semibold">{d.force_trigger_draw_gap}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-12">
|
||||
<div className="space-y-3 xl:col-span-8">
|
||||
{canManageJackpot ? (
|
||||
<div className="space-y-2 rounded-lg border border-border/60 bg-background p-3">
|
||||
<p className="text-sm font-medium">{t("balanceAdjustmentTitle")}</p>
|
||||
<p className="text-muted-foreground text-xs">{t("balanceAdjustmentHint")}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("adjustmentDirection")}</Label>
|
||||
<Select
|
||||
value={adj.direction}
|
||||
onValueChange={(value: "increase" | "decrease") =>
|
||||
updateAdjustmentDraft(p.id, { direction: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0 sm:max-w-[12rem]">
|
||||
<SelectValue>
|
||||
{(value) =>
|
||||
value === "increase"
|
||||
? t("adjustmentIncrease")
|
||||
: value === "decrease"
|
||||
? t("adjustmentDecrease")
|
||||
: value != null
|
||||
? String(value)
|
||||
: t("adjustmentIncrease")
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="increase">{t("adjustmentIncrease")}</SelectItem>
|
||||
<SelectItem value="decrease">{t("adjustmentDecrease")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`adj-amt-${p.id}`}>{t("adjustmentAmount")}</Label>
|
||||
<Input
|
||||
id={`adj-amt-${p.id}`}
|
||||
className="font-mono"
|
||||
value={adj.amount}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor={`adj-reason-${p.id}`}>{t("adjustmentReason")}</Label>
|
||||
<Textarea
|
||||
id={`adj-reason-${p.id}`}
|
||||
rows={1}
|
||||
value={adj.reason}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { reason: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={adjustingId === p.id}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirmAdjustmentTitle"),
|
||||
description: t("confirmAdjustmentDescription"),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => submitAdjustment(p),
|
||||
})
|
||||
}
|
||||
>
|
||||
{adjustingId === p.id ? t("processing") : t("submitAdjustment")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<fieldset
|
||||
disabled={!canManageJackpot}
|
||||
className="grid gap-2 rounded-lg border border-border/60 bg-background p-3 sm:grid-cols-2"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`adj-amt-${p.id}`}>{t("adjustmentAmount")}</Label>
|
||||
<Label htmlFor={`th-${p.id}`}>{t("triggerThreshold")}</Label>
|
||||
<Input
|
||||
id={`adj-amt-${p.id}`}
|
||||
id={`th-${p.id}`}
|
||||
className="font-mono"
|
||||
value={adj.amount}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { amount: e.target.value })}
|
||||
value={d.trigger_threshold}
|
||||
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2 xl:col-span-2">
|
||||
<Label htmlFor={`adj-reason-${p.id}`}>{t("adjustmentReason")}</Label>
|
||||
<Textarea
|
||||
id={`adj-reason-${p.id}`}
|
||||
rows={2}
|
||||
value={adj.reason}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { reason: e.target.value })}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`min-${p.id}`}>{t("minBetAmount")}</Label>
|
||||
<Input
|
||||
id={`min-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.min_bet_amount}
|
||||
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={adjustingId === p.id}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirmAdjustmentTitle"),
|
||||
description: t("confirmAdjustmentDescription"),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => submitAdjustment(p),
|
||||
})
|
||||
}
|
||||
>
|
||||
{adjustingId === p.id ? t("processing") : t("submitAdjustment")}
|
||||
</Button>
|
||||
</div>
|
||||
{ledger.length > 0 ? (
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<p className="text-xs font-medium">{t("recentAdjustments")}</p>
|
||||
<ul className="text-muted-foreground space-y-1 font-mono text-xs">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`pr-${p.id}`}>{t("payoutRate")}</Label>
|
||||
<Input
|
||||
id={`pr-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.payout_rate}
|
||||
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`gap-${p.id}`}>{t("forceTriggerGap")}</Label>
|
||||
<Input
|
||||
id={`gap-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.force_trigger_draw_gap}
|
||||
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
|
||||
<Input
|
||||
id={`cr-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.contribution_rate}
|
||||
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`combo-${p.id}`}>{t("comboTriggerPlays")}</Label>
|
||||
<Input
|
||||
id={`combo-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.combo_trigger_play_codes}
|
||||
placeholder="straight,ibox"
|
||||
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 sm:col-span-2">
|
||||
<Label htmlFor={`status-${p.id}`} className="text-sm font-medium">
|
||||
{t("status")}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`status-${p.id}`}
|
||||
checked={statusOn}
|
||||
disabled={!canManageJackpot}
|
||||
aria-label={t("status")}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDraft(p.id, { status: checked ? "1" : "0" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{canManageJackpot ? (
|
||||
<div className="flex justify-end sm:col-span-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={savingId === p.id}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirmSavePoolTitle"),
|
||||
description: t("confirmSavePoolDescription"),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => save(p),
|
||||
})
|
||||
}
|
||||
>
|
||||
{savingId === p.id ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 xl:col-span-4">
|
||||
<div className="rounded-lg border border-border/60 bg-background p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">{t("recentAdjustments")}</p>
|
||||
<span className="text-muted-foreground text-xs">{ledger.length}</span>
|
||||
</div>
|
||||
{ledger.length > 0 ? (
|
||||
<ul className="max-h-60 space-y-2 overflow-y-auto pr-1">
|
||||
{ledger.map((row) => (
|
||||
<li key={row.id}>
|
||||
{row.adjustment_no} · {row.amount_delta > 0 ? "+" : ""}
|
||||
{formatAdminMinorDecimal(row.amount_delta, p.currency_code)} ·{" "}
|
||||
{row.reason}
|
||||
<li key={row.id} className="rounded-md border border-border/60 bg-muted/20 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-xs">{row.adjustment_no}</span>
|
||||
<span className="font-mono text-xs">
|
||||
{row.amount_delta > 0 ? "+" : ""}
|
||||
{formatAdminMinorDecimal(row.amount_delta, p.currency_code)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">{row.reason}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<fieldset disabled={!canManageJackpot} className="grid gap-4 border-0 p-0 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
|
||||
<Input
|
||||
id={`cr-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.contribution_rate}
|
||||
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`th-${p.id}`}>{t("triggerThreshold")}</Label>
|
||||
<Input
|
||||
id={`th-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.trigger_threshold}
|
||||
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`pr-${p.id}`}>{t("payoutRate")}</Label>
|
||||
<Input
|
||||
id={`pr-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.payout_rate}
|
||||
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`gap-${p.id}`}>{t("forceTriggerGap")}</Label>
|
||||
<Input
|
||||
id={`gap-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.force_trigger_draw_gap}
|
||||
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`min-${p.id}`}>{t("minBetAmount")}</Label>
|
||||
<Input
|
||||
id={`min-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.min_bet_amount}
|
||||
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`combo-${p.id}`}>{t("comboTriggerPlays")}</Label>
|
||||
<Input
|
||||
id={`combo-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.combo_trigger_play_codes}
|
||||
placeholder="straight,ibox"
|
||||
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2.5">
|
||||
<Label htmlFor={`status-${p.id}`} className="text-sm font-medium">
|
||||
{t("status")}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`status-${p.id}`}
|
||||
checked={d.status === "1"}
|
||||
disabled={!canManageJackpot}
|
||||
aria-label={t("status")}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDraft(p.id, { status: checked ? "1" : "0" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
{canManageJackpot ? (
|
||||
<div className="flex justify-end border-t border-border/60 pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={savingId === p.id}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirmSavePoolTitle"),
|
||||
description: t("confirmSavePoolDescription"),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => save(p),
|
||||
})
|
||||
}
|
||||
>
|
||||
{savingId === p.id ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{canManualBurst ? (
|
||||
<div className="rounded-lg border border-amber-200/80 bg-amber-50/80 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
|
||||
<p className="mb-1 text-xs font-medium text-amber-900 dark:text-amber-200">
|
||||
{t("manualBurst")}
|
||||
</p>
|
||||
<p className="mb-3 text-xs text-amber-800/90 dark:text-amber-300/90">{t("manualBurstHint")}</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
|
||||
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
|
||||
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
|
||||
<Input
|
||||
id={`burst-draw-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.manual_burst_draw_id}
|
||||
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">—</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-background p-3">
|
||||
<p className="text-muted-foreground text-xs">{t("triggerThreshold")}</p>
|
||||
<p className="mt-0.5 font-mono text-sm">{triggerThreshold}</p>
|
||||
<p className="text-muted-foreground mt-2 text-xs">{t("minBetAmount")}</p>
|
||||
<p className="mt-0.5 font-mono text-sm">{minBetAmount}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="shrink-0 sm:ml-auto"
|
||||
disabled={burstingId === p.id}
|
||||
onClick={() => setConfirmBurstPoolId(p.id)}
|
||||
>
|
||||
{burstingId === p.id ? t("processing") : t("manualBurst")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{canManualBurst ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3">
|
||||
<p className="mb-1 text-sm font-medium text-destructive">{t("manualBurst")}</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{t("manualBurstHint")}</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
|
||||
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
|
||||
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
|
||||
<Input
|
||||
id={`burst-draw-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.manual_burst_draw_id}
|
||||
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="shrink-0 sm:ml-auto"
|
||||
disabled={burstingId === p.id}
|
||||
onClick={() => setConfirmBurstPoolId(p.id)}
|
||||
>
|
||||
{burstingId === p.id ? t("processing") : t("manualBurst")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
||||
@@ -87,8 +88,11 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const canFreezePlayers = adminHasAnyPermission(profile?.permissions, [PRD_PLAYER_FREEZE_MANAGE]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [siteCode, setSiteCode] = useState("");
|
||||
const [appliedSiteCode, setAppliedSiteCode] = useState("");
|
||||
|
||||
const [items, setItems] = useState<AdminPlayerRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -124,6 +128,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
page,
|
||||
per_page: perPage,
|
||||
keyword: query.trim() || undefined,
|
||||
site_code: appliedSiteCode.trim() || undefined,
|
||||
});
|
||||
setItems(data.items);
|
||||
setTotal(data.meta.total);
|
||||
@@ -137,7 +142,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, query, t]);
|
||||
}, [page, perPage, query, appliedSiteCode, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -297,6 +302,24 @@ export function PlayersConsole(): React.ReactElement {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="admin-list-toolbar">
|
||||
{canChooseSite ? (
|
||||
<div className="admin-list-field">
|
||||
<Label className="sm:w-20 sm:shrink-0">{t("filterSite")}</Label>
|
||||
<Select value={siteCode || "__all__"} onValueChange={(v) => setSiteCode(v === "__all__" ? "" : v)}>
|
||||
<SelectTrigger className="w-full sm:w-[12rem]">
|
||||
<SelectValue placeholder={t("filterAllSites")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.code} value={site.code}>
|
||||
{site.code} — {site.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
|
||||
{t("search")}
|
||||
@@ -311,6 +334,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
if (e.key === "Enter") {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
setAppliedSiteCode(siteCode.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -326,6 +350,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
setAppliedSiteCode(siteCode.trim());
|
||||
}}
|
||||
>
|
||||
{t("search")}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminTicketItems } from "@/api/admin-tickets";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
@@ -19,6 +20,13 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -33,18 +41,22 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
/** 与玩家端、注项表 status 字段对齐(不含无效的 success) */
|
||||
const TICKET_STATUS_OPTIONS = [
|
||||
"pending_confirm",
|
||||
"partial_pending_confirm",
|
||||
"placed",
|
||||
"pending_draw",
|
||||
"success",
|
||||
"partial_failed",
|
||||
"failed",
|
||||
"pending_payout",
|
||||
"settled_win",
|
||||
"settled_lose",
|
||||
"refunded",
|
||||
] as const;
|
||||
|
||||
type TicketFilters = {
|
||||
siteCode: string;
|
||||
playerQuery: string;
|
||||
drawNo: string;
|
||||
numberKeyword: string;
|
||||
@@ -54,6 +66,7 @@ type TicketFilters = {
|
||||
};
|
||||
|
||||
const emptyTicketFilters: TicketFilters = {
|
||||
siteCode: "",
|
||||
playerQuery: "",
|
||||
drawNo: "",
|
||||
numberKeyword: "",
|
||||
@@ -87,6 +100,7 @@ function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
|
||||
|
||||
export function PlayerTicketsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["tickets", "common"]);
|
||||
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
const exportLabels = useExportLabels("tickets");
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
@@ -115,6 +129,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
page,
|
||||
per_page: perPage,
|
||||
...query,
|
||||
site_code: applied.siteCode.trim() || undefined,
|
||||
draw_no: applied.drawNo.trim() || undefined,
|
||||
status: applied.statuses.length > 0 ? applied.statuses : undefined,
|
||||
number: applied.numberKeyword.trim() || undefined,
|
||||
@@ -140,6 +155,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
setErr(null);
|
||||
setApplied({
|
||||
...draft,
|
||||
siteCode: draft.siteCode.trim(),
|
||||
playerQuery: draft.playerQuery.trim(),
|
||||
drawNo: draft.drawNo.trim(),
|
||||
numberKeyword: draft.numberKeyword.trim(),
|
||||
@@ -170,6 +186,32 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
{canChooseSite ? (
|
||||
<div className="admin-list-field">
|
||||
<Label className="sm:shrink-0">{t("filterSite")}</Label>
|
||||
<Select
|
||||
value={draft.siteCode || "__all__"}
|
||||
onValueChange={(v) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
siteCode: v === "__all__" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[12rem]">
|
||||
<SelectValue placeholder={t("filterAllSites")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.code} value={site.code}>
|
||||
{site.code} — {site.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
|
||||
<Label htmlFor="pt-player" className="sm:shrink-0">
|
||||
{t("playerId")}
|
||||
|
||||
79
src/types/api/admin-integration-site.ts
Normal file
79
src/types/api/admin-integration-site.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export type AdminIntegrationSiteRow = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
currency_code: string;
|
||||
status: number;
|
||||
wallet_api_url: string | null;
|
||||
wallet_timeout_seconds: number;
|
||||
has_sso_secret: boolean;
|
||||
has_wallet_api_key: boolean;
|
||||
sso_secret_masked: string | null;
|
||||
wallet_api_key_masked: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type AdminIntegrationSiteDetail = AdminIntegrationSiteRow & {
|
||||
wallet_debit_path: string;
|
||||
wallet_credit_path: string;
|
||||
wallet_balance_path: string;
|
||||
iframe_allowed_origins: string[];
|
||||
lottery_h5_base_url: string | null;
|
||||
notes: string | null;
|
||||
is_default: boolean;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
export type AdminIntegrationSiteSecrets = {
|
||||
sso_jwt_secret: string;
|
||||
wallet_api_key: string;
|
||||
};
|
||||
|
||||
export type AdminIntegrationSiteListData = {
|
||||
items: AdminIntegrationSiteRow[];
|
||||
};
|
||||
|
||||
export type AdminIntegrationSiteCreatePayload = {
|
||||
code: string;
|
||||
name: string;
|
||||
currency_code?: string;
|
||||
status?: number;
|
||||
wallet_api_url?: string | null;
|
||||
wallet_debit_path?: string;
|
||||
wallet_credit_path?: string;
|
||||
wallet_balance_path?: string;
|
||||
wallet_timeout_seconds?: number;
|
||||
iframe_allowed_origins?: string[];
|
||||
lottery_h5_base_url?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type AdminIntegrationSiteUpdatePayload = Omit<AdminIntegrationSiteCreatePayload, "code">;
|
||||
|
||||
export type AdminIntegrationSiteWithSecrets = AdminIntegrationSiteDetail & {
|
||||
secrets?: AdminIntegrationSiteSecrets;
|
||||
secrets_display_once?: boolean;
|
||||
};
|
||||
|
||||
export type AdminIntegrationSiteConnectivityProbe = {
|
||||
success: boolean;
|
||||
main_balance_minor: number | null;
|
||||
currency_code: string;
|
||||
request_url: string;
|
||||
http_status: number | null;
|
||||
message: string | null;
|
||||
response_preview?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type AdminIntegrationSiteConnectivityResult = {
|
||||
site_code: string;
|
||||
site_player_id: string;
|
||||
player_source: "database" | "synthetic";
|
||||
probe: AdminIntegrationSiteConnectivityProbe;
|
||||
};
|
||||
|
||||
export type AdminIntegrationSiteParameterSheet = Record<string, unknown> & {
|
||||
site_code: string;
|
||||
name: string;
|
||||
security_note: string;
|
||||
};
|
||||
Reference in New Issue
Block a user