feat(api, i18n): add agent_node_id to various admin queries and enhance multi-language support

Introduced the agent_node_id field in AdminDrawListQuery, AdminPlayerListQuery, AdminSettlementBatchListQuery, TicketItemsListQuery, and TransferOrderListQuery to improve filtering capabilities. Updated the admin-breadcrumb and admin-sidebar components to include new translations for agent-related terms in English, Nepali, and Chinese, enhancing the overall user experience and multi-language support across the admin interface.
This commit is contained in:
2026-06-02 14:37:08 +08:00
parent a4e7a2d228
commit b15e377187
105 changed files with 5305 additions and 1596 deletions

View File

@@ -0,0 +1,92 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAdminSettings } from "@/api/admin-settings";
/** 系统设置页一次拉取的分组(避免各卡片重复 GET */
export const SYSTEM_SETTINGS_GROUPS = ["draw", "settlement", "frontend", "wallet"] as const;
function mergeItemsToKv(
items: { key: string; value: unknown }[],
into: Record<string, unknown>,
): void {
for (const item of items) {
into[item.key] = item.value;
}
}
type AdminSettingsDataContextValue = {
kv: Record<string, unknown> | null;
loading: boolean;
reload: () => Promise<void>;
patchKv: (updates: Record<string, unknown>) => void;
};
const AdminSettingsDataContext = createContext<AdminSettingsDataContextValue | null>(null);
export function AdminSettingsDataProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation(["config"]);
const [kv, setKv] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const tRef = useRef(t);
tRef.current = t;
const reload = useCallback(async () => {
setLoading(true);
try {
const responses = await Promise.all(
SYSTEM_SETTINGS_GROUPS.map((group) => getAdminSettings(group)),
);
const merged: Record<string, unknown> = {};
for (const res of responses) {
mergeItemsToKv(res.items, merged);
}
setKv(merged);
} catch {
toast.error(tRef.current("system.loadFailed", { ns: "config" }));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const patchKv = useCallback((updates: Record<string, unknown>) => {
setKv((prev) => (prev === null ? { ...updates } : { ...prev, ...updates }));
}, []);
const value = useMemo(
() => ({ kv, loading, reload, patchKv }),
[kv, loading, reload, patchKv],
);
return (
<AdminSettingsDataContext.Provider value={value}>{children}</AdminSettingsDataContext.Provider>
);
}
export function useAdminSettingsData(): AdminSettingsDataContextValue {
const ctx = useContext(AdminSettingsDataContext);
if (ctx === null) {
throw new Error("useAdminSettingsData must be used within AdminSettingsDataProvider");
}
return ctx;
}
export function useOptionalAdminSettingsData(): AdminSettingsDataContextValue | null {
return useContext(AdminSettingsDataContext);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { Button } from "@/components/ui/button";
export function SettingsSectionActions({
dirty,
loading,
saving,
onSave,
onDiscard,
saveLabel,
savingLabel,
discardLabel,
}: {
dirty: boolean;
loading: boolean;
saving: boolean;
onSave: () => void;
onDiscard: () => void;
saveLabel: string;
savingLabel: string;
discardLabel: string;
}) {
return (
<div className="flex flex-wrap items-center gap-3 pt-2">
<Button type="button" onClick={onSave} disabled={!dirty || loading || saving}>
{saving ? savingLabel : saveLabel}
</Button>
{dirty ? (
<Button type="button" variant="outline" onClick={onDiscard} disabled={saving}>
{discardLabel}
</Button>
) : null}
</div>
);
}

View File

@@ -1,9 +1,11 @@
"use client";
import { Pencil, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -68,6 +70,7 @@ function toFormState(row: AdminCurrencyRow): CurrencyFormState {
export function CurrencySettingsPanel() {
const { t } = useTranslation(["config", "adminUsers"]);
const tRef = useTranslationRef(["config", "adminUsers"]);
const exportLabels = useExportLabels("currencies");
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.currency.manage"]);
@@ -96,18 +99,16 @@ export function CurrencySettingsPanel() {
toast.error(
error instanceof LotteryApiBizError
? error.message
: t("currencies.loadFailed", { ns: "config" }),
: tRef.current("currencies.loadFailed", { ns: "config" }),
);
} finally {
setLoading(false);
}
}, [canManage, t]);
}, [canManage]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [canManage]);
function openCreate(): void {
setMode("create");

View File

@@ -0,0 +1,99 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { updateAdminSettingsBatch, type AdminSettingBatchItem } from "@/api/admin-settings";
import { setCachedApplyRebateToPayoutSetting } from "@/lib/admin-settlement-settings-cache";
import { useAdminSettingsData } from "@/modules/settings/admin-settings-data-context";
import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
import { LotteryApiBizError } from "@/types/api/errors";
export function useSettingsSection<TDraft>(options: {
initialDraft: TDraft;
fromKv: (kv: Record<string, unknown>) => TDraft;
buildDirtyItems: (draft: TDraft, saved: TDraft) => AdminSettingBatchItem[];
saveSuccessKey: string;
saveFailedKey: string;
}) {
const { t } = useTranslation(["config"]);
const tRef = useRef(t);
tRef.current = t;
const { kv, loading, patchKv } = useAdminSettingsData();
const [draft, setDraft] = useState(options.initialDraft);
const [saved, setSaved] = useState(options.initialDraft);
const [saving, setSaving] = useState(false);
const hydratedRef = useRef(false);
const { fromKv, buildDirtyItems, saveSuccessKey, saveFailedKey } = options;
const dirty = useMemo(
() => buildDirtyItems(draft, saved).length > 0,
[draft, saved, buildDirtyItems],
);
useEffect(() => {
if (kv === null) {
return;
}
const next = fromKv(kv);
setDraft(next);
setSaved(next);
hydratedRef.current = true;
}, [kv, fromKv]);
const updateField = <K extends keyof TDraft>(field: K, value: TDraft[K]) => {
setDraft((prev) => ({ ...prev, [field]: value }));
};
const discard = () => {
setDraft(saved);
};
const save = async (): Promise<boolean> => {
const items = buildDirtyItems(draft, saved);
if (items.length === 0) {
return true;
}
setSaving(true);
try {
await updateAdminSettingsBatch(items);
const updates: Record<string, unknown> = {};
for (const item of items) {
updates[item.key] = item.value;
if (item.key === SETTLEMENT_KEYS.APPLY_REBATE_TO_PAYOUT) {
setCachedApplyRebateToPayoutSetting(Boolean(item.value));
}
}
patchKv(updates);
setSaved(draft);
toast.success(tRef.current(saveSuccessKey, { ns: "config" }));
return true;
} catch (error) {
toast.error(
error instanceof LotteryApiBizError
? error.message
: tRef.current(saveFailedKey, { ns: "config" }),
);
return false;
} finally {
setSaving(false);
}
};
const sectionLoading = loading || (kv !== null && !hydratedRef.current);
return {
draft,
saved,
loading: sectionLoading,
saving,
dirty,
updateField,
discard,
save,
};
}

View File

@@ -0,0 +1,148 @@
"use client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface CurrencyFormatDraft {
currencyDisplayDecimals: string;
currencyDecimalSeparator: string;
currencyThousandsSeparator: string;
}
const INITIAL: CurrencyFormatDraft = {
currencyDisplayDecimals: "2",
currencyDecimalSeparator: ".",
currencyThousandsSeparator: ",",
};
function fromKv(kv: Record<string, unknown>): CurrencyFormatDraft {
return {
currencyDisplayDecimals: String(kv[DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS] ?? 2),
currencyDecimalSeparator: String(kv[DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR] ?? "."),
currencyThousandsSeparator: String(kv[DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR] ?? ","),
};
}
function buildDirtyItems(draft: CurrencyFormatDraft, saved: CurrencyFormatDraft): AdminSettingBatchItem[] {
const items: AdminSettingBatchItem[] = [];
if (draft.currencyDisplayDecimals !== saved.currencyDisplayDecimals) {
items.push({
key: DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS,
value: Math.max(
0,
Math.min(12, Number.parseInt(draft.currencyDisplayDecimals || "2", 10) || 2),
),
});
}
if (draft.currencyDecimalSeparator !== saved.currencyDecimalSeparator) {
items.push({
key: DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR,
value: (draft.currencyDecimalSeparator || ".").slice(0, 1),
});
}
if (draft.currencyThousandsSeparator !== saved.currencyThousandsSeparator) {
items.push({
key: DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR,
value: (draft.currencyThousandsSeparator || ",").slice(0, 1),
});
}
return items;
}
export function CurrencyFormatSettingsPanel() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const buildItems = useCallback(buildDirtyItems, []);
const section = useSettingsSection({
initialDraft: INITIAL,
fromKv,
buildDirtyItems: buildItems,
saveSuccessKey: "system.saveCurrencyFormatSuccess",
saveFailedKey: "system.saveFailed",
});
const { draft, loading, saving, dirty, updateField, discard, save } = section;
return (
<>
<AdminPageCard
title={t("system.sections.currencyFormat", { ns: "config" })}
description={t("system.sections.currencyFormatDescription", { ns: "config" })}
>
<div className="space-y-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="currency-display-decimals" className="text-sm font-medium">
{t("system.fields.currencyDisplayDecimals", { ns: "config" })}
</Label>
<Input
id="currency-display-decimals"
type="number"
min="0"
max="12"
step="1"
value={draft.currencyDisplayDecimals}
onChange={(e) => updateField("currencyDisplayDecimals", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currency-decimal-separator" className="text-sm font-medium">
{t("system.fields.currencyDecimalSeparator", { ns: "config" })}
</Label>
<Input
id="currency-decimal-separator"
value={draft.currencyDecimalSeparator}
onChange={(e) => updateField("currencyDecimalSeparator", e.target.value)}
disabled={loading || saving}
maxLength={1}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currency-thousands-separator" className="text-sm font-medium">
{t("system.fields.currencyThousandsSeparator", { ns: "config" })}
</Label>
<Input
id="currency-thousands-separator"
value={draft.currencyThousandsSeparator}
onChange={(e) => updateField("currencyThousandsSeparator", e.target.value)}
disabled={loading || saving}
maxLength={1}
/>
</div>
</div>
<SettingsSectionActions
dirty={dirty}
loading={loading}
saving={saving}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveCurrencyFormatTitle", { ns: "config" }),
description: t("system.confirmSaveCurrencyFormatDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => {
void save();
},
})
}
onDiscard={discard}
saveLabel={t("actions.save", { ns: "adminUsers" })}
savingLabel={t("saving", { ns: "adminUsers" })}
discardLabel={t("system.discard", { ns: "config" })}
/>
</div>
</AdminPageCard>
<ConfirmDialog />
</>
);
}

View File

@@ -0,0 +1,234 @@
"use client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface DrawDraft {
defaultCurrency: string;
drawIntervalMinutes: string;
drawBettingWindowSeconds: string;
drawCloseBeforeDrawSeconds: string;
drawBufferDrawsAhead: string;
requireManualReview: boolean;
cooldownMinutes: string;
}
const INITIAL: DrawDraft = {
defaultCurrency: "NPR",
drawIntervalMinutes: "5",
drawBettingWindowSeconds: "270",
drawCloseBeforeDrawSeconds: "30",
drawBufferDrawsAhead: "8",
requireManualReview: false,
cooldownMinutes: "15",
};
function fromKv(kv: Record<string, unknown>): DrawDraft {
return {
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
};
}
function buildDirtyItems(draft: DrawDraft, saved: DrawDraft): AdminSettingBatchItem[] {
const items: AdminSettingBatchItem[] = [];
const push = (key: string, value: unknown, changed: boolean) => {
if (changed) {
items.push({ key, value });
}
};
push(
DRAW_KEYS.DEFAULT_CURRENCY,
draft.defaultCurrency.trim().toUpperCase() || "NPR",
draft.defaultCurrency !== saved.defaultCurrency,
);
push(
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
draft.drawIntervalMinutes !== saved.drawIntervalMinutes,
);
push(
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
draft.drawBettingWindowSeconds !== saved.drawBettingWindowSeconds,
);
push(
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
draft.drawCloseBeforeDrawSeconds !== saved.drawCloseBeforeDrawSeconds,
);
push(
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
draft.drawBufferDrawsAhead !== saved.drawBufferDrawsAhead,
);
push(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview, draft.requireManualReview !== saved.requireManualReview);
push(
DRAW_KEYS.COOLDOWN_MINUTES,
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
draft.cooldownMinutes !== saved.cooldownMinutes,
);
return items;
}
export function DrawSettingsPanel() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const buildItems = useCallback(buildDirtyItems, []);
const section = useSettingsSection({
initialDraft: INITIAL,
fromKv,
buildDirtyItems: buildItems,
saveSuccessKey: "system.saveDrawSuccess",
saveFailedKey: "system.saveFailed",
});
const { draft, loading, saving, dirty, updateField, discard, save } = section;
return (
<>
<AdminPageCard
title={t("system.sections.draw", { ns: "config" })}
description={t("system.sections.drawDescription", { ns: "config" })}
>
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
<Switch
checked={draft.requireManualReview}
disabled={loading || saving}
aria-label={t("system.fields.manualReview", { ns: "config" })}
onCheckedChange={(value) => updateField("requireManualReview", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="default-currency" className="text-sm font-medium">
{t("system.fields.defaultCurrency", { ns: "config" })}
</Label>
<Input
id="default-currency"
value={draft.defaultCurrency}
onChange={(e) => updateField("defaultCurrency", e.target.value.toUpperCase())}
disabled={loading || saving}
maxLength={16}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-interval-minutes" className="text-sm font-medium">
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
</Label>
<Input
id="draw-interval-minutes"
type="number"
min="1"
max="1440"
step="1"
value={draft.drawIntervalMinutes}
onChange={(e) => updateField("drawIntervalMinutes", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-betting-window-seconds" className="text-sm font-medium">
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
</Label>
<Input
id="draw-betting-window-seconds"
type="number"
min="10"
step="1"
value={draft.drawBettingWindowSeconds}
onChange={(e) => updateField("drawBettingWindowSeconds", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-close-before-seconds" className="text-sm font-medium">
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
</Label>
<Input
id="draw-close-before-seconds"
type="number"
min="5"
step="1"
value={draft.drawCloseBeforeDrawSeconds}
onChange={(e) => updateField("drawCloseBeforeDrawSeconds", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-buffer-ahead" className="text-sm font-medium">
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
</Label>
<Input
id="draw-buffer-ahead"
type="number"
min="1"
step="1"
value={draft.drawBufferDrawsAhead}
onChange={(e) => updateField("drawBufferDrawsAhead", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
{t("system.fields.cooldownMinutes", { ns: "config" })}
</Label>
<Input
id="cooldown-minutes"
type="number"
min="0"
step="1"
value={draft.cooldownMinutes}
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
disabled={loading || saving}
/>
</div>
</div>
<SettingsSectionActions
dirty={dirty}
loading={loading}
saving={saving}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveDrawTitle", { ns: "config" }),
description: t("system.confirmSaveDrawDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => {
void save();
},
})
}
onDiscard={discard}
saveLabel={t("actions.save", { ns: "adminUsers" })}
savingLabel={t("saving", { ns: "adminUsers" })}
discardLabel={t("system.discard", { ns: "config" })}
/>
</div>
</AdminPageCard>
<ConfirmDialog />
</>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
import { FRONTEND_KEYS } from "@/modules/settings/settings-keys";
import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
interface FrontendDraft {
playRulesHtmlZh: string;
playRulesHtmlEn: string;
playRulesHtmlNe: string;
}
const INITIAL: FrontendDraft = {
playRulesHtmlZh: "",
playRulesHtmlEn: "",
playRulesHtmlNe: "",
};
function fromKv(kv: Record<string, unknown>): FrontendDraft {
const legacyHtml = String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? "");
return {
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
};
}
function buildDirtyItems(draft: FrontendDraft, saved: FrontendDraft): AdminSettingBatchItem[] {
const items: AdminSettingBatchItem[] = [];
if (draft.playRulesHtmlZh !== saved.playRulesHtmlZh) {
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML_ZH, value: draft.playRulesHtmlZh });
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML, value: draft.playRulesHtmlZh });
}
if (draft.playRulesHtmlEn !== saved.playRulesHtmlEn) {
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML_EN, value: draft.playRulesHtmlEn });
}
if (draft.playRulesHtmlNe !== saved.playRulesHtmlNe) {
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML_NE, value: draft.playRulesHtmlNe });
}
return items;
}
export function FrontendSettingsPanel() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const buildItems = useCallback(buildDirtyItems, []);
const section = useSettingsSection({
initialDraft: INITIAL,
fromKv,
buildDirtyItems: buildItems,
saveSuccessKey: "system.saveFrontendSuccess",
saveFailedKey: "system.saveFailed",
});
const { draft, loading, saving, dirty, updateField, discard, save } = section;
return (
<>
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
<div className="grid gap-2">
<Label className="text-sm font-medium">
{t("system.fields.playRulesHtml", { ns: "config" })}
</Label>
<p className="text-xs text-muted-foreground">
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
</p>
<Tabs defaultValue="zh" className="w-full">
<TabsList className="w-full max-w-md">
<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="play-rules-html-zh"
value={draft.playRulesHtmlZh}
onChange={(e) => updateField("playRulesHtmlZh", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
<TabsContent value="en" className="mt-3">
<Textarea
id="play-rules-html-en"
value={draft.playRulesHtmlEn}
onChange={(e) => updateField("playRulesHtmlEn", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
<TabsContent value="ne" className="mt-3">
<Textarea
id="play-rules-html-ne"
value={draft.playRulesHtmlNe}
onChange={(e) => updateField("playRulesHtmlNe", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
</Tabs>
<SettingsSectionActions
dirty={dirty}
loading={loading}
saving={saving}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveFrontendTitle", { ns: "config" }),
description: t("system.confirmSaveFrontendDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => {
void save();
},
})
}
onDiscard={discard}
saveLabel={t("actions.save", { ns: "adminUsers" })}
savingLabel={t("saving", { ns: "adminUsers" })}
discardLabel={t("system.discard", { ns: "config" })}
/>
</div>
</AdminPageCard>
<ConfirmDialog />
</>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface SettlementDraft {
autoSettlement: boolean;
autoApprove: boolean;
autoPayout: boolean;
applyRebateToPayout: boolean;
}
const INITIAL: SettlementDraft = {
autoSettlement: true,
autoApprove: true,
autoPayout: true,
applyRebateToPayout: false,
};
function fromKv(kv: Record<string, unknown>): SettlementDraft {
return {
autoSettlement: Boolean(kv[SETTLEMENT_KEYS.AUTO_SETTLEMENT] ?? true),
autoApprove: Boolean(kv[SETTLEMENT_KEYS.AUTO_APPROVE] ?? true),
autoPayout: Boolean(kv[SETTLEMENT_KEYS.AUTO_PAYOUT] ?? true),
applyRebateToPayout: Boolean(kv[SETTLEMENT_KEYS.APPLY_REBATE_TO_PAYOUT] ?? false),
};
}
function buildDirtyItems(draft: SettlementDraft, saved: SettlementDraft): AdminSettingBatchItem[] {
const items: AdminSettingBatchItem[] = [];
if (draft.autoSettlement !== saved.autoSettlement) {
items.push({ key: SETTLEMENT_KEYS.AUTO_SETTLEMENT, value: draft.autoSettlement });
}
if (draft.autoApprove !== saved.autoApprove) {
items.push({ key: SETTLEMENT_KEYS.AUTO_APPROVE, value: draft.autoApprove });
}
if (draft.autoPayout !== saved.autoPayout) {
items.push({ key: SETTLEMENT_KEYS.AUTO_PAYOUT, value: draft.autoPayout });
}
if (draft.applyRebateToPayout !== saved.applyRebateToPayout) {
items.push({ key: SETTLEMENT_KEYS.APPLY_REBATE_TO_PAYOUT, value: draft.applyRebateToPayout });
}
return items;
}
export function SettlementSettingsPanel() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const buildItems = useCallback(buildDirtyItems, []);
const section = useSettingsSection({
initialDraft: INITIAL,
fromKv,
buildDirtyItems: buildItems,
saveSuccessKey: "system.saveSettlementSuccess",
saveFailedKey: "system.saveFailed",
});
const { draft, loading, saving, dirty, updateField, discard, save } = section;
return (
<>
<AdminPageCard
title={t("system.sections.settlement", { ns: "config" })}
description={t("system.sections.settlementDescription", { ns: "config" })}
>
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
<Switch
checked={draft.autoSettlement}
disabled={loading || saving}
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
onCheckedChange={(value) => updateField("autoSettlement", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
<Switch
checked={draft.autoApprove}
disabled={loading || saving}
aria-label={t("system.fields.autoApprove", { ns: "config" })}
onCheckedChange={(value) => updateField("autoApprove", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
<Switch
checked={draft.autoPayout}
disabled={loading || saving}
aria-label={t("system.fields.autoPayout", { ns: "config" })}
onCheckedChange={(value) => updateField("autoPayout", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 space-y-1 pr-4">
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
</div>
<Switch
checked={draft.applyRebateToPayout}
disabled={loading || saving}
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
onCheckedChange={(value) => updateField("applyRebateToPayout", value)}
/>
</div>
<SettingsSectionActions
dirty={dirty}
loading={loading}
saving={saving}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveSettlementTitle", { ns: "config" }),
description: t("system.confirmSaveSettlementDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => {
void save();
},
})
}
onDiscard={discard}
saveLabel={t("actions.save", { ns: "adminUsers" })}
savingLabel={t("saving", { ns: "adminUsers" })}
discardLabel={t("system.discard", { ns: "config" })}
/>
</div>
</AdminPageCard>
<ConfirmDialog />
</>
);
}

View File

@@ -0,0 +1,38 @@
export const DRAW_GROUP = "draw";
export const SETTLEMENT_GROUP = "settlement";
export const FRONTEND_GROUP = "frontend";
export const WALLET_GROUP = "wallet";
export const DRAW_KEYS = {
DEFAULT_CURRENCY: "currency.default_code",
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
COOLDOWN_MINUTES: "draw.cooldown_minutes",
CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals",
CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator",
CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator",
} as const;
export const SETTLEMENT_KEYS = {
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
AUTO_APPROVE: "settlement.auto_approve_on_tick",
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
APPLY_REBATE_TO_PAYOUT: "settlement.apply_rebate_to_payout",
} as const;
export const FRONTEND_KEYS = {
PLAY_RULES_HTML: "frontend.play_rules_html",
PLAY_RULES_HTML_ZH: "frontend.play_rules_html_zh",
PLAY_RULES_HTML_EN: "frontend.play_rules_html_en",
PLAY_RULES_HTML_NE: "frontend.play_rules_html_ne",
} as const;
export const WALLET_KEYS = {
IN_MIN: "wallet.transfer_in_min_minor",
IN_MAX: "wallet.transfer_in_max_minor",
OUT_MIN: "wallet.transfer_out_min_minor",
OUT_MAX: "wallet.transfer_out_max_minor",
} as const;

View File

@@ -1,568 +1,22 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getAdminSettings,
updateAdminSetting,
} from "@/api/admin-settings";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LotteryApiBizError } from "@/types/api/errors";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminSettingsDataProvider } from "@/modules/settings/admin-settings-data-context";
import { CurrencyFormatSettingsPanel } from "@/modules/settings/panels/currency-format-settings-panel";
import { DrawSettingsPanel } from "@/modules/settings/panels/draw-settings-panel";
import { FrontendSettingsPanel } from "@/modules/settings/panels/frontend-settings-panel";
import { SettlementSettingsPanel } from "@/modules/settings/panels/settlement-settings-panel";
import { useTranslation } from "react-i18next";
const DRAW_GROUP = "draw";
const SETTLEMENT_GROUP = "settlement";
const DRAW_KEYS = {
DEFAULT_CURRENCY: "currency.default_code",
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
COOLDOWN_MINUTES: "draw.cooldown_minutes",
CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals",
CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator",
CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator",
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
AUTO_APPROVE: "settlement.auto_approve_on_tick",
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
APPLY_REBATE_TO_PAYOUT: "settlement.apply_rebate_to_payout",
} as const;
const FRONTEND_GROUP = "frontend";
const FRONTEND_KEYS = {
PLAY_RULES_HTML: "frontend.play_rules_html",
PLAY_RULES_HTML_ZH: "frontend.play_rules_html_zh",
PLAY_RULES_HTML_EN: "frontend.play_rules_html_en",
PLAY_RULES_HTML_NE: "frontend.play_rules_html_ne",
} as const;
interface RuntimeDraft {
defaultCurrency: string;
drawIntervalMinutes: string;
drawBettingWindowSeconds: string;
drawCloseBeforeDrawSeconds: string;
drawBufferDrawsAhead: string;
requireManualReview: boolean;
cooldownMinutes: string;
currencyDisplayDecimals: string;
currencyDecimalSeparator: string;
currencyThousandsSeparator: string;
autoSettlement: boolean;
autoApprove: boolean;
autoPayout: boolean;
applyRebateToPayout: boolean;
playRulesHtmlZh: string;
playRulesHtmlEn: string;
playRulesHtmlNe: string;
}
const RUNTIME_DRAFT_KEYS = [
"defaultCurrency",
"drawIntervalMinutes",
"drawBettingWindowSeconds",
"drawCloseBeforeDrawSeconds",
"drawBufferDrawsAhead",
"requireManualReview",
"cooldownMinutes",
"currencyDisplayDecimals",
"currencyDecimalSeparator",
"currencyThousandsSeparator",
"autoSettlement",
"autoApprove",
"autoPayout",
"applyRebateToPayout",
] as const satisfies readonly (keyof RuntimeDraft)[];
const FRONTEND_DRAFT_KEYS = [
"playRulesHtmlZh",
"playRulesHtmlEn",
"playRulesHtmlNe",
] as const satisfies readonly (keyof RuntimeDraft)[];
function isSectionDirty<const K extends keyof RuntimeDraft>(
draft: RuntimeDraft,
saved: RuntimeDraft,
keys: readonly K[],
): boolean {
return keys.some((key) => draft[key] !== saved[key]);
}
function applyDraftFields<const K extends keyof RuntimeDraft>(
base: RuntimeDraft,
source: RuntimeDraft,
keys: readonly K[],
): RuntimeDraft {
const next = { ...base };
for (const key of keys) {
next[key] = source[key];
}
return next;
}
function SaveActions({
dirty,
loading,
saving,
onSave,
onDiscard,
saveLabel,
savingLabel,
discardLabel,
}: {
dirty: boolean;
loading: boolean;
saving: boolean;
onSave: () => void;
onDiscard: () => void;
saveLabel: string;
savingLabel: string;
discardLabel: string;
}) {
return (
<div className="flex flex-wrap items-center gap-3 pt-2">
<Button type="button" onClick={onSave} disabled={!dirty || loading || saving}>
{saving ? savingLabel : saveLabel}
</Button>
{dirty ? (
<Button type="button" variant="outline" onClick={onDiscard}>
{discardLabel}
</Button>
) : null}
</div>
);
}
export function SystemSettingsScreen() {
const { t } = useTranslation(["common", "config", "adminUsers"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [draft, setDraft] = useState<RuntimeDraft>({
defaultCurrency: "NPR",
drawIntervalMinutes: "5",
drawBettingWindowSeconds: "270",
drawCloseBeforeDrawSeconds: "30",
drawBufferDrawsAhead: "8",
requireManualReview: false,
cooldownMinutes: "15",
currencyDisplayDecimals: "2",
currencyDecimalSeparator: ".",
currencyThousandsSeparator: ",",
autoSettlement: true,
autoApprove: true,
autoPayout: true,
applyRebateToPayout: false,
playRulesHtmlZh: "",
playRulesHtmlEn: "",
playRulesHtmlNe: "",
});
const [saved, setSaved] = useState<RuntimeDraft>({
defaultCurrency: "NPR",
drawIntervalMinutes: "5",
drawBettingWindowSeconds: "270",
drawCloseBeforeDrawSeconds: "30",
drawBufferDrawsAhead: "8",
requireManualReview: false,
cooldownMinutes: "15",
currencyDisplayDecimals: "2",
currencyDecimalSeparator: ".",
currencyThousandsSeparator: ",",
autoSettlement: true,
autoApprove: true,
autoPayout: true,
applyRebateToPayout: false,
playRulesHtmlZh: "",
playRulesHtmlEn: "",
playRulesHtmlNe: "",
});
const [loading, setLoading] = useState(true);
const [savingRuntime, setSavingRuntime] = useState(false);
const [savingFrontend, setSavingFrontend] = useState(false);
const runtimeDirty = useMemo(
() => isSectionDirty(draft, saved, RUNTIME_DRAFT_KEYS),
[draft, saved],
);
const frontendDirty = useMemo(
() => isSectionDirty(draft, saved, FRONTEND_DRAFT_KEYS),
[draft, saved],
);
const anyDirty = runtimeDirty || frontendDirty;
const saving = savingRuntime || savingFrontend;
const load = useCallback(async () => {
setLoading(true);
try {
const [drawRes, settlementRes, frontendRes] = await Promise.all([
getAdminSettings(DRAW_GROUP),
getAdminSettings(SETTLEMENT_GROUP),
getAdminSettings(FRONTEND_GROUP),
]);
const kv: Record<string, unknown> = {};
for (const item of [...drawRes.items, ...settlementRes.items, ...frontendRes.items]) {
kv[item.key] = item.value;
}
const legacyHtml = String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? "");
const nextDraft: RuntimeDraft = {
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
currencyDisplayDecimals: String(kv[DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS] ?? 2),
currencyDecimalSeparator: String(kv[DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR] ?? "."),
currencyThousandsSeparator: String(kv[DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR] ?? ","),
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
autoApprove: Boolean(kv[DRAW_KEYS.AUTO_APPROVE] ?? true),
autoPayout: Boolean(kv[DRAW_KEYS.AUTO_PAYOUT] ?? true),
applyRebateToPayout: Boolean(kv[DRAW_KEYS.APPLY_REBATE_TO_PAYOUT] ?? false),
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
};
setDraft(nextDraft);
setSaved(nextDraft);
} catch {
toast.error(t("system.loadFailed", { ns: "config" }));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
const updateDraft = <K extends keyof RuntimeDraft>(field: K, value: RuntimeDraft[K]) => {
setDraft((prev) => ({ ...prev, [field]: value }));
};
const discardSection = <const K extends keyof RuntimeDraft>(keys: readonly K[]) => {
setDraft((prev) => applyDraftFields(prev, saved, keys));
};
const handleSaveRuntime = async () => {
setSavingRuntime(true);
try {
await updateAdminSetting(
DRAW_KEYS.DEFAULT_CURRENCY,
draft.defaultCurrency.trim().toUpperCase() || "NPR",
);
await updateAdminSetting(
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
);
await updateAdminSetting(
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
);
await updateAdminSetting(
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
);
await updateAdminSetting(
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
);
await updateAdminSetting(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview);
await updateAdminSetting(
DRAW_KEYS.COOLDOWN_MINUTES,
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
);
await updateAdminSetting(
DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS,
Math.max(0, Math.min(12, Number.parseInt(draft.currencyDisplayDecimals || "2", 10) || 2)),
);
await updateAdminSetting(
DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR,
(draft.currencyDecimalSeparator || ".").slice(0, 1),
);
await updateAdminSetting(
DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR,
(draft.currencyThousandsSeparator || ",").slice(0, 1),
);
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove);
await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout);
await updateAdminSetting(DRAW_KEYS.APPLY_REBATE_TO_PAYOUT, draft.applyRebateToPayout);
toast.success(t("system.saveRuntimeSuccess", { ns: "config" }));
setSaved((prev) => applyDraftFields(prev, draft, RUNTIME_DRAFT_KEYS));
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
);
} finally {
setSavingRuntime(false);
}
};
const handleSaveFrontend = async () => {
setSavingFrontend(true);
try {
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML, draft.playRulesHtmlZh);
toast.success(t("system.saveFrontendSuccess", { ns: "config" }));
setSaved((prev) => applyDraftFields(prev, draft, FRONTEND_DRAFT_KEYS));
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
);
} finally {
setSavingFrontend(false);
}
};
const saveLabel = t("actions.save", { ns: "adminUsers" });
const savingLabel = t("saving", { ns: "adminUsers" });
const discardLabel = t("system.discard", { ns: "config" });
function SystemSettingsContent() {
const { t } = useTranslation(["config"]);
return (
<div className="flex w-full max-w-none flex-col gap-6">
{anyDirty ? (
<div className="sticky top-0 z-20 -mx-1 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 shadow-sm backdrop-blur-sm">
<p className="text-sm font-medium text-amber-950 dark:text-amber-100">
{t("system.unsavedChanges", { ns: "config" })}
{runtimeDirty && frontendDirty
? ` · ${t("system.title", { ns: "config" })} / ${t("system.frontendConfig", { ns: "config" })}`
: runtimeDirty
? ` · ${t("system.title", { ns: "config" })}`
: ` · ${t("system.frontendConfig", { ns: "config" })}`}
</p>
</div>
) : null}
<AdminPageCard
title={t("system.title", { ns: "config" })}
description={t("system.description", { ns: "config" })}
>
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
<Switch
checked={draft.requireManualReview}
disabled={loading || saving}
aria-label={t("system.fields.manualReview", { ns: "config" })}
onCheckedChange={(value) => updateDraft("requireManualReview", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="default-currency" className="text-sm font-medium">
{t("system.fields.defaultCurrency", { ns: "config" })}
</Label>
<Input
id="default-currency"
value={draft.defaultCurrency}
onChange={(e) => updateDraft("defaultCurrency", e.target.value.toUpperCase())}
disabled={loading || saving}
maxLength={16}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-interval-minutes" className="text-sm font-medium">
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
</Label>
<Input
id="draw-interval-minutes"
type="number"
min="1"
max="1440"
step="1"
value={draft.drawIntervalMinutes}
onChange={(e) => updateDraft("drawIntervalMinutes", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-betting-window-seconds" className="text-sm font-medium">
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
</Label>
<Input
id="draw-betting-window-seconds"
type="number"
min="10"
step="1"
value={draft.drawBettingWindowSeconds}
onChange={(e) => updateDraft("drawBettingWindowSeconds", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-close-before-seconds" className="text-sm font-medium">
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
</Label>
<Input
id="draw-close-before-seconds"
type="number"
min="5"
step="1"
value={draft.drawCloseBeforeDrawSeconds}
onChange={(e) => updateDraft("drawCloseBeforeDrawSeconds", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-buffer-ahead" className="text-sm font-medium">
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
</Label>
<Input
id="draw-buffer-ahead"
type="number"
min="1"
step="1"
value={draft.drawBufferDrawsAhead}
onChange={(e) => updateDraft("drawBufferDrawsAhead", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currency-display-decimals" className="text-sm font-medium">
{t("system.fields.currencyDisplayDecimals", { ns: "config" })}
</Label>
<Input
id="currency-display-decimals"
type="number"
min="0"
max="12"
step="1"
value={draft.currencyDisplayDecimals}
onChange={(e) => updateDraft("currencyDisplayDecimals", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currency-decimal-separator" className="text-sm font-medium">
{t("system.fields.currencyDecimalSeparator", { ns: "config" })}
</Label>
<Input
id="currency-decimal-separator"
value={draft.currencyDecimalSeparator}
onChange={(e) => updateDraft("currencyDecimalSeparator", e.target.value)}
disabled={loading || saving}
maxLength={1}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currency-thousands-separator" className="text-sm font-medium">
{t("system.fields.currencyThousandsSeparator", { ns: "config" })}
</Label>
<Input
id="currency-thousands-separator"
value={draft.currencyThousandsSeparator}
onChange={(e) => updateDraft("currencyThousandsSeparator", e.target.value)}
disabled={loading || saving}
maxLength={1}
/>
</div>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
<Switch
checked={draft.autoSettlement}
disabled={loading || saving}
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
onCheckedChange={(value) => updateDraft("autoSettlement", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
<Switch
checked={draft.autoApprove}
disabled={loading || saving}
aria-label={t("system.fields.autoApprove", { ns: "config" })}
onCheckedChange={(value) => updateDraft("autoApprove", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
<Switch
checked={draft.autoPayout}
disabled={loading || saving}
aria-label={t("system.fields.autoPayout", { ns: "config" })}
onCheckedChange={(value) => updateDraft("autoPayout", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 space-y-1 pr-4">
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
</div>
<Switch
checked={draft.applyRebateToPayout}
disabled={loading || saving}
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
onCheckedChange={(value) => updateDraft("applyRebateToPayout", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="grid max-w-xs gap-2">
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
{t("system.fields.cooldownMinutes", { ns: "config" })}
</Label>
<Input
id="cooldown-minutes"
type="number"
min="0"
step="1"
value={draft.cooldownMinutes}
onChange={(e) => updateDraft("cooldownMinutes", e.target.value)}
disabled={loading || saving}
/>
</div>
<SaveActions
dirty={runtimeDirty}
loading={loading}
saving={savingRuntime}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveRuntimeTitle", { ns: "config" }),
description: t("system.confirmSaveRuntimeDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => handleSaveRuntime(),
})
}
onDiscard={() => discardSection(RUNTIME_DRAFT_KEYS)}
saveLabel={saveLabel}
savingLabel={savingLabel}
discardLabel={discardLabel}
/>
</div>
</AdminPageCard>
<DrawSettingsPanel />
<CurrencyFormatSettingsPanel />
<SettlementSettingsPanel />
<AdminPageCard
title={t("wallet.title", { ns: "config" })}
@@ -571,73 +25,15 @@ export function SystemSettingsScreen() {
<WalletConfigDocScreen embedded />
</AdminPageCard>
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
<div className="grid gap-2">
<Label className="text-sm font-medium">
{t("system.fields.playRulesHtml", { ns: "config" })}
</Label>
<p className="text-xs text-muted-foreground">
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
</p>
<Tabs defaultValue="zh" className="w-full">
<TabsList className="w-full max-w-md">
<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="play-rules-html-zh"
value={draft.playRulesHtmlZh}
onChange={(e) => updateDraft("playRulesHtmlZh", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
<TabsContent value="en" className="mt-3">
<Textarea
id="play-rules-html-en"
value={draft.playRulesHtmlEn}
onChange={(e) => updateDraft("playRulesHtmlEn", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
<TabsContent value="ne" className="mt-3">
<Textarea
id="play-rules-html-ne"
value={draft.playRulesHtmlNe}
onChange={(e) => updateDraft("playRulesHtmlNe", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
</Tabs>
<SaveActions
dirty={frontendDirty}
loading={loading}
saving={savingFrontend}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveFrontendTitle", { ns: "config" }),
description: t("system.confirmSaveFrontendDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => handleSaveFrontend(),
})
}
onDiscard={() => discardSection(FRONTEND_DRAFT_KEYS)}
saveLabel={saveLabel}
savingLabel={savingLabel}
discardLabel={discardLabel}
/>
</div>
</AdminPageCard>
<ConfirmDialog />
<FrontendSettingsPanel />
</div>
);
}
export function SystemSettingsScreen() {
return (
<AdminSettingsDataProvider>
<SystemSettingsContent />
</AdminSettingsDataProvider>
);
}