diff --git a/src/api/admin-integration-sites.ts b/src/api/admin-integration-sites.ts new file mode 100644 index 0000000..bdd958b --- /dev/null +++ b/src/api/admin-integration-sites.ts @@ -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 { + return adminRequest.get(`${A}/integration-sites`); +} + +export async function getAdminIntegrationSite(id: number): Promise { + return adminRequest.get(`${A}/integration-sites/${id}`); +} + +export async function postAdminIntegrationSite( + body: AdminIntegrationSiteCreatePayload, +): Promise { + return adminRequest.post(`${A}/integration-sites`, body); +} + +export async function putAdminIntegrationSite( + id: number, + body: AdminIntegrationSiteUpdatePayload, +): Promise { + return adminRequest.put(`${A}/integration-sites/${id}`, body); +} + +export async function postAdminIntegrationSiteRotateSecrets( + id: number, +): Promise { + return adminRequest.post( + `${A}/integration-sites/${id}/rotate-secrets`, + {}, + ); +} + +export async function postAdminIntegrationSiteConnectivityTest( + id: number, + body: { site_player_id: string; currency_code?: string }, +): Promise { + return adminRequest.post( + `${A}/integration-sites/${id}/connectivity-test`, + body, + ); +} + +export async function getAdminIntegrationSiteExport( + id: number, + format: "json" | "csv" = "json", +): Promise { + return adminRequest.get( + `${A}/integration-sites/${id}/export`, + { params: { format } }, + ); +} + diff --git a/src/api/admin-player.ts b/src/api/admin-player.ts index 735715a..4a33031 100644 --- a/src/api/admin-player.ts +++ b/src/api/admin-player.ts @@ -17,6 +17,7 @@ export async function getAdminPlayers(params?: { per_page?: number; keyword?: string; status?: number; + site_code?: string; }): Promise { return adminRequest.get(`${A}/players`, { params }); } diff --git a/src/api/admin-tickets.ts b/src/api/admin-tickets.ts index 855dc35..c6c426c 100644 --- a/src/api/admin-tickets.ts +++ b/src/api/admin-tickets.ts @@ -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; diff --git a/src/app/admin/(shell)/config/integration-sites/page.tsx b/src/app/admin/(shell)/config/integration-sites/page.tsx new file mode 100644 index 0000000..cf48f17 --- /dev/null +++ b/src/app/admin/(shell)/config/integration-sites/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/hooks/use-admin-site-code-options.ts b/src/hooks/use-admin-site-code-options.ts new file mode 100644 index 0000000..2a6499b --- /dev/null +++ b/src/hooks/use-admin-site-code-options.ts @@ -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; +} { + const profile = useAdminProfile(); + const canLoad = adminHasAnyPermission(profile?.permissions, [ + "prd.integration.view", + "prd.integration.manage", + ]); + + const [sites, setSites] = useState([]); + 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, + }; +} diff --git a/src/i18n/locales/en/config.json b/src/i18n/locales/en/config.json index 4aa7c28..9517eae 100644 --- a/src/i18n/locales/en/config.json +++ b/src/i18n/locales/en/config.json @@ -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", diff --git a/src/i18n/locales/en/players.json b/src/i18n/locales/en/players.json index 7441705..ef2b2d1 100644 --- a/src/i18n/locales/en/players.json +++ b/src/i18n/locales/en/players.json @@ -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", diff --git a/src/i18n/locales/en/tickets.json b/src/i18n/locales/en/tickets.json index f0b9d92..5bce758 100644 --- a/src/i18n/locales/en/tickets.json +++ b/src/i18n/locales/en/tickets.json @@ -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", diff --git a/src/i18n/locales/zh/config.json b/src/i18n/locales/zh/config.json index 093cfae..d12e672 100644 --- a/src/i18n/locales/zh/config.json +++ b/src/i18n/locales/zh/config.json @@ -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": "生效中", diff --git a/src/i18n/locales/zh/players.json b/src/i18n/locales/zh/players.json index cb08fee..fa5089e 100644 --- a/src/i18n/locales/zh/players.json +++ b/src/i18n/locales/zh/players.json @@ -3,6 +3,8 @@ "listTitle": "玩家列表", "createPlayer": "新建玩家", "searchPlaceholder": "按玩家 ID / 用户名 / 昵称搜索", + "filterSite": "主站站点", + "filterAllSites": "全部站点", "search": "搜索", "refresh": "刷新", "loadFailed": "加载玩家列表失败", diff --git a/src/i18n/locales/zh/tickets.json b/src/i18n/locales/zh/tickets.json index 3e8b69b..1d8b572 100644 --- a/src/i18n/locales/zh/tickets.json +++ b/src/i18n/locales/zh/tickets.json @@ -1,6 +1,8 @@ { "title": "注单列表", "playerTicketQuery": "注单查询", + "filterSite": "主站站点", + "filterAllSites": "全部站点", "playerId": "玩家 ID / 账号", "invalidPlayerId": "请输入有效玩家 ID 或账号", "playerIdPlaceholder": "留空显示全部,可输入玩家 ID 或账号", diff --git a/src/lib/admin-page-title.ts b/src/lib/admin-page-title.ts index a81a1e2..e812209 100644 --- a/src/lib/admin-page-title.ts +++ b/src/lib/admin-page-title.ts @@ -24,6 +24,7 @@ const EXACT_ROUTES: Record = { "/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" }, diff --git a/src/modules/config/config-hub-screen.tsx b/src/modules/config/config-hub-screen.tsx index c7f3c8a..2a42d21 100644 --- a/src/modules/config/config-hub-screen.tsx +++ b/src/modules/config/config-hub-screen.tsx @@ -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() { diff --git a/src/modules/integration/integration-sites-console.tsx b/src/modules/integration/integration-sites-console.tsx new file mode 100644 index 0000000..e191f77 --- /dev/null +++ b/src/modules/integration/integration-sites-console.tsx @@ -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 { + 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([]); + 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(null); + const [form, setForm] = useState(EMPTY_FORM); + const [rotateTarget, setRotateTarget] = useState(null); + const [rotateBusy, setRotateBusy] = useState(false); + const [secretsDialog, setSecretsDialog] = useState<{ + siteCode: string; + secrets: AdminIntegrationSiteSecrets; + } | null>(null); + const [connectivityTarget, setConnectivityTarget] = useState( + null, + ); + const [connectivityPlayerId, setConnectivityPlayerId] = useState("10001"); + const [connectivityCurrency, setConnectivityCurrency] = useState("NPR"); + const [connectivityBusy, setConnectivityBusy] = useState(false); + const [connectivityResult, setConnectivityResult] = + useState(null); + const [exportBusyId, setExportBusyId] = useState(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 { + 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(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 { + 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 { + 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 { + 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 { + 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 { + try { + await navigator.clipboard.writeText(value); + toast.success(t("integrationSites.copied", { field: label })); + } catch { + toast.error(t("integrationSites.copyFailed")); + } + } + + if (!canView) { + return ( + +

{t("integrationSites.noPermission")}

+
+ ); + } + + return ( + <> + + {t("integrationSites.create")} + + ) : null + } + > + {loading ? ( +

{t("integrationSites.loading")}

+ ) : items.length === 0 ? ( +

{t("integrationSites.empty")}

+ ) : ( + + + + {t("integrationSites.columns.code")} + {t("integrationSites.columns.name")} + {t("integrationSites.columns.status")} + {t("integrationSites.columns.walletUrl")} + {t("integrationSites.columns.actions")} + + + + {items.map((row) => ( + + {row.code} + {row.name} + + + + + {row.wallet_api_url ?? "—"} + + +
+ + + + {canManage ? ( + + ) : null} +
+
+
+ ))} +
+
+ )} +
+ + + + + + {mode === "create" + ? t("integrationSites.dialogCreateTitle") + : t("integrationSites.dialogEditTitle")} + + {t("integrationSites.dialogDescription")} + +
+
+ + updateForm("code", e.target.value)} + placeholder="partner-a" + /> + {mode === "edit" ? ( +

{t("integrationSites.codeImmutable")}

+ ) : null} +
+
+ + updateForm("name", e.target.value)} + /> +
+
+
+ + updateForm("currency_code", e.target.value)} + /> +
+
+ + +
+
+
+ + updateForm("wallet_api_url", e.target.value)} + /> +
+
+ + updateForm("lottery_h5_base_url", e.target.value)} + /> +
+
+ +